mirage (mirage-ai) is a virtual filesystem for AI
agents: it mounts a SaaS backend and lets you read it with plain bash — ls, cat, grep,
find, jq. These scripts point mirage at Backlot, so you can exercise a mirage-based
agent over a corpus you supply, entirely offline.
uv sync --all-extras --locked
python examples/using-mirage/slack.py # or gmail.py, gdrive.py, notion.py, s3.py, github.py, linear.py, unified.pyEach script spins up its own throwaway server on a tiny in-code corpus, points a mirage
VFS at it, and runs a few filesystem commands. Pass --url http://host:port to use an
already-running server instead (it falls back to a local one if that's unreachable).
python examples/using-mirage/unified.py --url https://your-backlot-host.example.comAdd --fuse to any source's script to expose the mount as a real OS filesystem instead
of driving it in-process (see FUSE mode below).
| Script | mirage VFS | mount | what it shows |
|---|---|---|---|
slack.py |
SlackVFS |
/slack |
ls channels → dated chat.jsonl; cat + scoped grep |
gmail.py |
GmailVFS |
/gmail |
ls labels → dates → messages; cat + jq .subject |
gdrive.py |
GoogleDriveVFS |
/gdrive |
ls -F folders → files; cat a native Google doc; in-process, gws docs documents create a Doc that the next ls lists |
notion.py |
NotionVFS |
/notion |
ls pages/ + databases/; cat a page.json / database.json |
s3.py |
S3VFS |
/s3 |
ls recursively → cat an object; grep -r across the bucket |
github.py |
GitHubVFS |
/github |
ls a repo's file tree → cat a source file; grep -r across it |
linear.py |
LinearVFS |
/linear |
ls teams → a team's team.json states; cat an issue.json, jq its comments.jsonl |
unified.py |
all three | /slack /gmail /gdrive |
one Workspace, one set of commands, three backends |
The scripts navigate top-down (ls one level, cat one file) rather than walking a whole
mount with find / grep -r. mirage materializes each directory by calling the API, so a
whole-mount walk over a large corpus (like the live deploy) means thousands of round-trips —
bounded navigation keeps them fast. find/grep -r still work; just scope them to a subtree.
Not included: Jira / Confluence (mirage has no connector for them). GitHub is included, but
only its code side: mirage's GitHub connector mirrors a repo's file tree via the git
trees/contents/blobs API — it has no notion of issues/PRs, which Backlot also serves as
GitHub documents. Use examples/using-mirage/github.py for the code crawl and
examples/using-official-sdk/github.py for issues/PRs (or
both, together).
Slack, Notion and Linear take a base_url config, so you point them straight at Backlot — no glue:
from backlot import serve_or_connect
with serve_or_connect(CORPUS) as s:
vfs = SlackVFS(SlackConfig(token=s.token,
base_url=f"{s.base_url}/slack/api"))
ws = Workspace({"/slack": vfs}, mode=MountMode.READ)
print(await (await ws.shell("ls /slack/channels/")).stdout_str())Notion is the same one-liner — NotionConfig(base_url=f"{s.base_url}/notion/v1"), no
monkeypatch. mirage sends Notion-Version: 2025-09-03, which Backlot's version-aware router
serves (the data_sources shape on a database, and its rows through data_sources/{id}/query),
so pages and databases both read correctly.
Linear is one line too — LinearConfig(api_key=token, base_url=f"{s.base_url}/linear/graphql").
mirage sends the key bare in Authorization, the way Linear takes a personal API key, so a Backlot
token goes into api_key unchanged. A team's directory holds team.json beside issues/,
members/, projects/, cycles/ and documents/; documents/ is empty, because a corpus carries
no Linear documents.
S3 is also plain config, no monkeypatch and no pin bump: S3Config(endpoint_url= f"{s.base_url}/s3", path_style=True, aws_access_key_id=ak, aws_secret_access_key=sk).
path_style=True keeps the bucket in the path (/s3/<bucket>/...) rather than the hostname. S3
uses an AWS keypair (not a bearer token): --access-key/--secret-key are required with
--url (real AWS keys, or a pair from GET <url>/_meta/users — the keys the SigV4 verifier
accepts); without --url the local throwaway server uses its own admin keypair.
Google has a knob, GoogleConfig.api_base, and it is the single that rules it out rather
than its absence: mirage composes Docs, Slides and Forms alike as {api_base}/v1, while Backlot
serves docs at /docs/v1 and slides at /slides/v1, so all three would arrive on one prefix it
cannot route apart. So backlot.integrations.mirage exposes point_google_at(base_url), which
rewrites the per-API constants the helpers fall back to when api_base is unset:
from backlot.integrations.mirage import point_google_at
point_google_at(s.base_url) # googleapis.com -> Backlot
gmail = GmailVFS(GmailConfig(**creds))It redirects the OAuth token endpoint, the Google Drive API, and the Docs/Sheets/Slides APIs (mirage
reads native Google docs structurally through those, not via Google Drive export) — each to a distinct
Backlot path, so Docs and Slides don't collide. The --url / --user / --token flags behave
exactly as in the using-official-sdk examples: serve_or_connect comes from backlot itself,
and google_oauth_user (Backlot-specific OAuth glue, not general API) from
examples/_common/google_creds.py.
GitHub is the same shape as Google, but with one constant:
mirage.core.github.constants.API_BASE = "https://api.github.com", which mirage falls back to
whenever GitHubConfig.base_url is unset. point_github_at(base_url) rebinds that constant (and
any already-imported copy) before the VFS is built:
from backlot.integrations.mirage import point_github_at
point_github_at(s.base_url) # api.github.com -> Backlot
repo = GitHubVFS(GitHubConfig(token=T, owner="acme", repo="gateway"))The copy-sweep matters here for the same reason it does for Google: mirage/core/github/client.py
imports API_BASE by value and reads that copy in github_url, so patching only the constants
module would leave every request pointed at api.github.com. What differs from Google is that
GitHubConfig.base_url is a real seam — mirage threads it to every call site — so the rebind is
what redirects a VFS built without that field, rather than the only way in.
Everything above drives the filesystem in-process with ws.shell("ls …"). mirage can also
expose a mount as a real filesystem via FUSE, so any process — cat, grep, rg, an
editor, an indexer — reads Backlot's data as ordinary files:
python examples/using-mirage/slack.py --url https://your-backlot-host.example.com --fuse
python examples/using-mirage/gmail.py --url https://your-backlot-host.example.com --user ceo@acme.com --fuse
python examples/using-mirage/gdrive.py --url https://your-backlot-host.example.com --fuseEach prints a real mountpoint (e.g. /tmp/mirage-xxxx), reads a file through the kernel's FUSE
layer with plain os/open(), and runs an external grep against it to prove it's a genuine
filesystem. While it's running you can ls/cat/grep the mountpoint from another terminal.
unified.py --fuse mounts the whole workspace root at a single mountpoint, so slack/,
gmail/, and gdrive/ show up as subdirectories of one mount — all three at once, on macOS
included. (macFUSE limits a process to one mountpoint, not one source, so a single mount that
serves several sources is fine; what fails is opening several separate mountpoints in one
process.)
Requirements:
uv sync --all-extras --lockedalready pullsmirage-ai[fuse,s3]— themfusepybinding, and theaioboto3thats3.py's backend imports.- An OS FUSE driver: macFUSE on macOS,
fuse3on Linux. Without it,--fuseprints install guidance and exits cleanly (the non---fusepath needs no driver).
Against a remote deploy the cost is dominated by (a) TLS/round-trip latency and (b) how many entries a directory has, because mirage materializes a directory by calling the API for every entry:
- Connection reuse —
run_mirage()(used by every script here) shares one keep-alive connection across mirage's many calls; without it each call pays a fresh TLS handshake (≈3x slower end to end over a remote hop). - Navigate, don't sweep —
lsone level andcatone file.find/grep -rover a whole mount force mirage to fetch every file; scope them to a subtree. - Listing a huge directory is inherently proportional to its size — e.g.
lsof a Google Drive folder with thousands of files, or a large mailbox label (mirage fetches every message to group by date). Prefer a--userwhose ACL-scoped view is smaller, or a narrower path.
Backlot's side of these paths was tuned alongside these examples (see git history): Slack
conversations.list is memoized and its created comes from an aggregate (was a full
per-channel message scan); Google Drive folder listings are SQL-scoped/paginated, resolve folder ids
without an ACL scan, batch the per-file ACL lookup, and honor the fields mask.
Same as the official-SDK examples: pair the identity flag with --url.
# Slack — a bearer token from GET /_meta/users
python examples/using-mirage/slack.py --url http://localhost:8000 --token <usr-token>
# Gmail / Google Drive — a user email (authorized-user credential, impersonating that user)
python examples/using-mirage/gdrive.py --url http://localhost:8000 --user mia@acme.comThe mounted filesystem then contains only what that identity is allowed to read.
Pointing mirage at Backlot surfaced a few gaps that are now part of Backlot (see the git
history): Google Drive's root is navigable ('root' in parents returns folder objects; shared-drives
enumeration is present-but-empty), the Docs/Sheets/Slides read APIs serve native-doc content, a
Slack channel's created never postdates its messages, and conversations.history honors
oldest/latest so a per-day fetch returns only that day (not the whole channel). Coverage
lives in tests/test_google.py.