- Ship your own backend: a single TypeScript file in your own project or package, built on
BaseVFS. 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, group the three required reads in aVFSAdapter, and BaseVFS wires the full generic command set (ls, cat, grep, find, head, wc, …) plus glob resolution and the VFS/FUSE ops:
readdir where stat belongs, or an accessor from another backend, is a compile error rather than a runtime one.
For a smaller runnable example, see examples/typescript/other/basevfs_views.ts.
Its read-only NotesVFS connects to a nested mount, a namespace symlink,
a CLI using namespace and session views, and the filesystem and runtime APIs.
The driver never sees the mount it runs under. The index store, the registered tables and the vfs: reference it was built from all live on the mount, built when the driver is placed and shared with any alias of the same instance. The loader recognizes a driver by the VFS_BRAND symbol BaseVFS stamps, so a script file that loaded its own copy of the package still mounts.
Add capabilities as the resource grows
Onlyreaddir, readBytes, and stat are required. The adapter derives a
stream from readBytes, existence checks from stat, and defaults to a remote
resource that is available. Range reads fall back to reading and slicing.
A derived stream still fetches the entire file; use a native stream for large files.
native and
writes for a minimal read-only adapter. These groups are independent:
A native range receives
(accessor, path, index, offset, size); size=null
means through EOF. Its write twin, pwrite, receives (accessor, path, data, offset) and keeps every byte outside the window, as pwrite(2) does. A table
with write and no append or pwrite gets both built from readBytes and
write, so supply them only when the backend writes a range natively. Use FileStat.size=null when rendered size is unknown.
du supplies both size and entry enumeration. All operations enforce the same
resource scope, including direct reads of known IDs. A partial listing cannot
prove an omitted resource absent.
Supplying a write callback does not enable deletion or rename. Every generic
command is registered either way: gzip -c and tar -t run as readers, a line
that needs a missing operation answers Operation not supported at that
operation, and mount mode still controls whether a supported mutation may run. Set local: true only for host-local data;
isMounted can override the default availability check.
adapter.toCommandIO() assembles the single CommandIO used by commands and
filesystem ops. Builtins compile VFSAdapter into this table; advanced integrations can also
supply CommandIO directly. The escape hatches remain:
overridesdrops a generic command you replace, andcommandssupplies the replacement (or any bespoke verb) fromcommand({...}).opslayers an irregular VFS/FUSE handler over the derived set; one carrying nofiletypeshadows the derived op of the same name.autoOps: falseopts out of deriving any. A verb the op table does not carry is not served: the mount answersOperation not supportedfor it, so a backend can be partial.sizesAlwaysKnowndeclares thatstatsizes every file without fetching it, which is also what makes the mount legal on FSKit.supportsSnapshotdeclares thatstatfillsFileStat.fingerprint; setting it without that is not drift detection.
How the driver connects to a workspace
BaseVFS is the backend authoring API. Mount it once with
new Workspace({ "/jira": new JiraVFS(config) }); consumers use workspace paths.
Use
path.vfsPath to address your backend and path.virtual for workspace
paths. Use the index passed to callbacks: the mount scopes it for ownership
and freshness. Put shared behavior in the adapter callbacks; an explicit
ops override changes that operation, while a commands override changes
that shell command. Optional mutations remain optional, and unsupported
operations report Operation not supported.
Native search and backend-specific core functions
Builtin backends use the sameVFSAdapter groups. Operation types live in
vfs/types; CommandIO extends them with command context. Wire compatible core
functions directly, and wrap incompatible client functions to resolve PathSpec
and normalize their results. All callbacks are checked against the adapter’s
accessor type.
SearchQuery contains query and optional options, for example
{ query: 'recent deployments', options: { limit: 20 } }. Options can hold
filters, limits, or other JSON values. SearchOps.meta is optional static
capability metadata. Regex support and grep compatibility are not required.
Omitting search entirely also works: MIRAGE implements grep and rg by reading
files.
A resource search is not automatically used by grep or rg. Opt into that
integration with { search, meta: { grep: { mode: 'literal' } } }, or use
mode: 'regex' when the backend honors regex semantics. Only that integration
interprets the grep namespace. Requests put ignore_case, fixed_string,
whole_word, and basic booleans in query.options.grep; these JSON keys are
snake_case in both languages. Other options and metadata belong to your resource.
Return text records: [] means no matches and null declines the request.
Errors propagate. Under the grep integration, records must be complete rendered
output lines, including path prefixes. A new filesystem search accelerator must
match scanning rendered files. Existing backends retain their declared output
semantics, including Langfuse’s summary search. Never report truncated results
as complete.
MIRAGE scans for unsupported flags, multiple operands, declined requests, and
visibility restrictions. Set meta: { grep: { mode: 'regex', stream: true } }
to enable native streaming for fallback scans. The hierarchy kit’s
makeSearchOp(detectScope, SEARCHERS, stat?) adapts scope-specific callbacks;
PostgreSQL, MongoDB and Langfuse use it. Custom resources can implement search
directly. Expose semantic or service-specific queries through a custom command
that calls the same callback with its own options. Native traversal and size
enumeration remain independent capabilities.
examples/typescript/other/basevfs_views.ts wires the same literal search callback
to both commands. Its counters verify that grep -F and rg -F avoid file reads,
line numbers and regexes scan, and -i scans after the callback returns null.
The example accelerates single-page searches; directory searches also decline.
To make the backend constructible by name (workspace config, snapshots, the daemon), register a factory:
parseConfigWithSchema to validate raw configuration at runtime and refuse unknown keys; a type assertion does not validate input. Keep the same validation when loading through a static create method.
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.
Validate at the constructor or static create boundary so direct construction, factories, and file references enforce the same config. A type assertion does not validate user input. There is no open step: a backend reaches its service lazily, on the first request, or does its setup in a static async create. Close clients owned by this VFS in override close() and call super.close(). The index store is the mount’s, and the mount closes it. Shared clients belong to the embedding program’s lifecycle.
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:
static async create is honored ahead of the constructor, which is how a backend whose setup needs I/O is spelled here.
When mounting a driver built with buildVfs in code, preserve its loader name on the placement: new Mount(await buildVfs('jira', config), { vfsRef: 'jira' }). Config loading does this automatically. Snapshots also preserve the mount’s effective index settings. An index URL containing credentials is redacted; pass a new Mount(vfs, { index }) override with fresh credentials when loading it.
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: one backend is four layers with one name (accessor, core, ops, VFS) plus its commands, and every layer has a Python twin. Change both languages in the same PR; where they disagree, the more correct side wins. 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. Paths are always PathSpec values inside the VFS; never pass a path as a raw string.
Most of a backend is already written as a kit. Reach for one before writing a layer by hand:
Two builtins are the references. Trello is the hierarchy kit end to end: a scope table, listers, id-addressed readers, and nested
trello <noun> <verb> commands. Jaeger is the client shape to copy: every call takes the mount’s transport, the one handle the accessor owns.
File Structure
<name>.test.ts beside the source it covers.
1. Config, Accessor, and Registry
Define the config as a zod schema and parse it throughparseConfigWithSchema, which refuses an unknown key by name, the way Python’s extra="forbid" does; mark credentials with secretStr() so the redactor masks them. Create an Accessor subclass that owns the 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 aFileStat.
core/<name>/scope.ts, and builds the three operations from it: makeReaddir(detectScope, { listers }), makeStat(detectScope, readdir, { entryStats }) and makeRead(detectScope, readers, { stat }). The kit holds these rules, and a new backend keeps them:
- A reader that reaches the API by the ids in the path slots passes
statso the kit proves the file’s parent through the listing first. Without it, a path outside the configured scope (Trello’sworkspaceIdandboardIds) reads whilelsandstatsay it does not exist. - A listing that is a filtered or truncated view (one page of a bounded query, a time window, a glob-scoped span) returns
{ entries, seeds: {}, partial: true }. Cached as the whole directory, it would prove every entry it left out absent. entryStat('<idKey>', ...)names the id under the key its path slot declares.- Id-addressed commands honor the same scope knobs the listing does (Trello’s
commands/builtin/trello/_scope.ts).
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. Set sizesAlwaysKnown only when every listed size is computed from the same payload a read renders.
Glob resolution is not a per-backend file: makeGenericOps derives a glob op from the table’s readdir, capped by its maxGlobMatches, and the mount expands patterns through it.
3. Ops Layer
BuildIO = new VFSAdapter({ read, native, writes }).toCommandIO() in commands/builtin/<name>/io.ts, omitting groups the backend does not implement.
Ops are the workspace dispatcher’s typed adapters onto the core functions, and they are generated, not hand-written:
overrides so the derived set skips it. Mark every mutation write: true.
4. Commands
Build the standard set withmakeGenericCommands(VFSName.X, X_IO, { overrides }) over the same table; the generic command owns flag interpretation, so a backend wrapper is wiring only. overrides names the builders a bespoke command replaces, and a name no builder has is refused. Export the result as <NAME>_COMMANDS from commands/builtin/<name>/index.ts.
- Declare native text search in
new VFSAdapter({ read, search }). Generic grep/rg builders consume it only whensearch.meta.grepopts in; semantic search does not opt in. The hierarchy kit suppliesmakeSearchOpfor scope-based core functions; unsupported requests fall back to scanning. Existing wrappers may callrunSearch(X_IO, name, ...)when preserving a custom registration. - A verb (
trello card create) declares its ownnew CommandSpec({...})with every id as a flag, reads flags throughnew FlagView(opts.flags, SPEC)(neverflags.get(...)), registerswrite: truefor a mutation, and callsrequireMountWritable(...)before the client. Specs declared inline are dumped to.cache/spec/typescript/<node|browser>/vfs_commands/and compared with Python’s, and a prompt that teaches the verbs is pinned against their specs (vfs/trello/prompt.test.ts). - A handler that reads its
accessordirectly is trusted host code. Admission judges the paths it is given, but no policy sees what it reads below them, so keep such a handler to its operands.
5. VFS Class
ExtendBaseVFS, declare the facts as members, and return the command and op arrays from commands() and ops(). Core functions stay independent of the VFS class:
cachesReads true only for stable, read-mostly content. BaseVFS supplies a no-op loadState() and a 600 s indexTtl, so declare one only to change it. A mount’s read ttl caps listing lifetimes. Override getState() to carry the (redacted) config, and close() to release any client handles, calling super.close(). The index store is the mount’s, built when the driver is placed, so a driver never sees or closes one.
A backend whose content lives in a remote service keeps needs_override: true in that state. Both loaders refuse a missing live override; neither substitutes an empty RAM mount. A backend that owns portable content can implement getState and loadState and register a factory so snapshots rebuild it, as shown above.
Point lookups under fresh
A read: fresh mount re-stats a cached file through a throwaway store that
starts with none of the mount’s rows (ListingCheckStore), so no cached
row answers the check.
A backend with a path lookup answers with one request for that path (Dropbox).
A backend that addresses items only by id may, after checking that the index
is a ListingCheckStore, read the mount’s last row for
the path with await index.hint(key) and address one request by its id (Box);
it must then confirm from that answer alone that the item still sits at
exactly this path, and otherwise resolve the path as it always does. The hint
is a lead, never an answer: a stat built from its fields would let a stale row
pass a freshness check.
Listing versions
Underread: fresh, a cached listing the running command did not write is listed again, unless the backend declares what to check it against. Declare listingVersion as a literal member, like readRevalidatable, so the spec generator can read it: override readonly listingVersion: ListingVersion = ListingVersion.MOUNT.
ListingVersion.NONE(the default): no version; every command re-lists.ListingVersion.MOUNT: one version covers every listing of the mount (GitHub and the Hub use the head commit). A stat of the mount root through the gate’s empty check store (ListingCheckStore,cache/index/ram) asks the backend and returns it asfingerprint; through any other index it names none and reads neither the index nor the backend, since nothing reads a root fingerprint off a mount-view stat. The tree fill stores the same value with every listing it writes (seed(..., version)orsetDir(..., { version })).ListingVersion.FOLDER: each listing carries its own folder’s version (disk). A stat of the folder returns it as the fingerprint, andreaddirreads it before listing and passes it tosetDir(path, entries, expiresAt, { version }), so a change during the scan leaves the stored version behind.
===. Store null when there is nothing reliable to store; that listing re-lists.
listingsPin (default null) is set per instance in the constructor when the config pins the mount to something that cannot move: the lowercased ref when it is a full 40- or 64-hex commit sha. A stored listing whose version equals the pin is served with no request. The stored value still comes from a response, never from config, so a full-sha ref is served unchecked only when its listing was fetched at that sha. github.com refuses a branch or tag named with 40 or 64 hex characters, so a full-sha GitHub ref always names a commit (a GitHub Enterprise host is assumed to do the same). The Hugging Face Hub repos set no pin: a Hub revision named like a sha is checked every command.
node/src/vfs/listing_version.test.ts holds every declarer to this, as tests/vfs/test_listing_version.py does in Python: the fill stores a version, a stat through an empty index answers the stored value, a second command sends only the expected checks, and an outside change moves the version. Live declaration parity is checked by scripts/check_spec_parity.py using the temporary manifests produced by both generators. A new declarer adds a harness to the test and adds its name to the pinned roster, or the roster tests fail.
6. Snapshot Support
LeavesupportsSnapshot unset unless the complete drift contract is implemented:
stat()returns a stableFileStat.fingerprint.- Every read record includes the fingerprint that produced those bytes.
- If the backend supports immutable revisions, reads consult the resolved revision and record it.
7. Verification
Exercise a custom backend at a nested prefix, with globs, unknown file sizes, a read-only mount, and a deliberately omitted mutation. Assert stdout, stderr, and exit status together. Use a small bounded page (three entries with five available) and request counters to check both cold and warm traversal costs. A partialDirListing caches positive membership until expiry; it never proves an omitted entry absent, and a subsequent directory read fetches a new page. Custom index stores may override setPartialDir; the default conservatively refreshes on lookup.
- Tests for config parsing (an unknown key refused), path layout, every VFS op, command behavior, read-only enforcement, scope enforcement, state redaction, and cleanup.
- Rebuild the dists (core, then node, then browser), regenerate
spec/withtypescript/scripts/gen-specs.tsandscripts/gen_specs.py, then runscripts/check_spec_parity.py,scripts/check_layout_parity.py --strictandpnpm typecheck. - An integration target in
integ/targets.jsonwith cases underinteg/vfs/<name>/, run by both hosts’ runners against the same goldens; a SaaS backend gets a fake underinteg/server/. Any change in observable shell behavior adds a case.
Updating Existing Backends
From GenericVFS and the VFS interface
BaseVFS is now the one driver contract, and a driver serves only through
the tables ops() and commands() return. Nothing keeps the old spelling
alive, so a custom backend written against it changes in these places:
Mount configs now reject unknown fields. Remove PostgreSQL’s
default_search_limit and MongoDB’s default_doc_limit and default_search_limit from YAML (or defaultSearchLimit and defaultDocLimit from TypeScript). The remaining read ceilings are max_read_rows / maxReadRows and max_doc_limit / maxDocLimit. PostgreSQL head/tail and MongoDB tail report a clipped window through stderr and a nonzero status; consumers must check status before treating captured output as complete. PostgreSQL refuses whole reads over its thresholds. MongoDB streams and search no longer apply a silent result cap.
BaseVFS provides loadState and indexTtl defaults, and has no open step. Subclasses that replace a member need override when noImplicitOverride is enabled. The hierarchy name helpers sanitize empty and dot-leading labels to reachable names; regenerate stored paths from the listing and keep the provider id as the stable identifier. Enforce read and mutation scope on every entry point, including direct ids, rather than relying on a previous listing.
PostgreSQL whole-file reads check a bounded result’s database JSON byte size before transferring rows, then check the rendered JSONL size. Database formatting can make the first check conservative; use an explicit row window when that guard refuses a read.
Check the adapter contract
The read-contract helper accepts either an adapter or its compiled I/O table. Supply a small known file, its parent directory, an absent sibling, and the expected bytes. It checks listing, metadata, reads, streams, native ranges, existence, and missing-path errors without mutating the resource.checkDriverContract(vfs, fixture) runs the same read checks against a whole
driver, through the op table vfs.ops() serves, the one channel a mount
dispatches to. It checks a builtin-shaped subclass as readily as a driver built
from an adapter, and always probes the read op’s byte window. DriverOps is
the table it drives: it calls a driver’s ops the way a mount does, with the
accessor bound and one index store per instance, which is also how to script a
driver outside a workspace.
BaseVFS built from an adapter serve through the
same two tables: commands, filesystem ops, and glob expansion all come from the
backend’s I/O table, and a driver carries no direct verb methods beside them.
Backend classes keep configuration, client lifecycle, storage location, watch
hooks, and snapshot behavior.
Batch resource search
{ search: searchOne, searchMany } optionally supplies a batch callback with
(accessor, paths, query, index). Use it when ranking and top_k must apply once
across several paths. The single-path callback remains required. The shared
searchResources helper uses the batch callback when provided and otherwise
concatenates single-path results; a declined request reports an error rather
than no matches.
Chroma, Dify, Qdrant, LanceDB, and Mem0 use this capability for their existing
resource search commands. Their options remain backend-specific: for example,
top_k, threshold, and method. These declarations do not opt into grep.
Start from a packaged example
Themirage-vfs-authoring skill in the Mirage plugin includes self-contained
Python and TypeScript starters and scripts/new_adapter.py. It creates an
adapter file in your project, refuses to overwrite existing files, and includes
the contract check plus a mounted shell smoke test. Replace its fixture client
with your resource API, then expand capabilities as needed.