> ## 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.

# Discord

> Mount Discord guilds, channels, and members as a virtual filesystem from Node or the browser.

MIRAGE ships `DiscordVFS` in **two runtimes**:

* `@struktoai/mirage-node`, talks to `https://discord.com/api/v10/*` directly using a bot token.
* `@struktoai/mirage-browser`, stays secret-free: a small proxy server on your backend holds the token and forwards `/api/discord/*` to `https://discord.com/api/v10/*`. The browser only ever sees the proxy URL.

Both runtimes expose the same filesystem shape (`/discord/<guild>/channels/<channel>/<date>/{chat.jsonl,files/}`, `/discord/<guild>/members/`) and the same shell commands, and both pair with the [discord CLI](https://docs.mirage.strukto.ai/typescript/cli/discord.md) for acting on the server.

## Get a bot token

1. Visit the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application.
2. Under **Bot**, add a bot user and copy the **Bot Token**.
3. Under **Bot → Privileged Gateway Intents**, enable:
   * **Server Members Intent**, required for `/discord/<guild>/members/` listings.
   * **Message Content Intent**, required to read message text in `.jsonl` history files.
4. Under **OAuth2 → URL Generator**, select the `bot` scope and the following bot permissions:
   * `Read Messages/View Channels`
   * `Read Message History`
   * `Send Messages` (only if you'll use `discord send`)
   * `Add Reactions` (only if you'll use `discord react`)
5. Open the generated URL in a browser and invite the bot to your guild.

```bash theme={null}
# .env.development
DISCORD_BOT_TOKEN=...
```

## Node (server-side)

```bash theme={null}
pnpm add @struktoai/mirage-node
```

```ts theme={null}
import { DiscordVFS, MountMode, Workspace } from '@struktoai/mirage-node'

const discord = new DiscordVFS({
  token: process.env.DISCORD_BOT_TOKEN!,
})

const ws = new Workspace({ '/discord': discord }, { mode: MountMode.READ })
const res = await ws.shell('ls /discord/')
console.log(res.stdoutText)
```

### Config

| Field | Description |
| - | - |
| `timeout` default `DEFAULT_TIMEOUT` | Seconds a request may wait to connect, or between two reads of its answer, before it fails; a download that keeps flowing is never cut off. |
| `token` required, secret | Discord bot token, sent as `Authorization: Bot <token>`. |
| `baseUrl` default `'https://discord.com/api/v10'` | Discord API root; override to point at a fake or proxy. |
| `startTime` | Start of the mount's time scope, inclusive: an RFC 3339 timestamp with an explicit timezone and at most millisecond precision. Unset leaves the start open. |
| `endTime` | End of the mount's time scope, exclusive, in the same format as `startTime`, which it must follow. Unset leaves the end open. |

Fields marked secret are redacted in snapshots.

## Browser

```bash theme={null}
pnpm add @struktoai/mirage-browser
```

The browser package never sees the bot token. Instead, point it at a relative URL that your backend proxies to `https://discord.com/api/v10/*`, attaching the `Authorization: Bot …` header server-side.

### Config

| Field | Description |
| - | - |
| `timeout` default `DEFAULT_TIMEOUT` | Seconds a request may wait to connect, or between two reads of its answer, before it fails; a download that keeps flowing is never cut off. |
| `startTime` | Start of the mount's time scope, inclusive: an RFC 3339 timestamp with an explicit timezone and at most millisecond precision. Unset leaves the start open. |
| `endTime` | End of the mount's time scope, exclusive, in the same format as `startTime`, which it must follow. Unset leaves the end open. |
| `proxyUrl` required | URL of your backend proxy, which forwards to `https://discord.com/api/v10` and attaches the bot token. A path resolves against the page's origin; no query string or fragment. |
| `getHeaders` secret | Function returning extra headers sent with every request to the proxy, such as your own session credential. |

Fields marked secret are redacted in snapshots.

### 1. Server: minimal proxy

```ts theme={null}
import { createServer } from 'node:http'

const TOKEN = process.env.DISCORD_BOT_TOKEN!
const PREFIX = '/api/discord/'

createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost')
  if (!url.pathname.startsWith(PREFIX)) {
    res.statusCode = 404
    res.end()
    return
  }
  const upstream = new URL(`https://discord.com/api/v10/${url.pathname.slice(PREFIX.length)}`)
  upstream.search = url.search

  const method = (req.method ?? 'GET').toUpperCase()
  const chunks: Buffer[] = []
  if (method !== 'GET' && method !== 'HEAD') {
    for await (const chunk of req) chunks.push(chunk as Buffer)
  }
  const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : undefined

  const headers: Record<string, string> = { Authorization: `Bot ${TOKEN}` }
  const ct = req.headers['content-type']
  if (typeof ct === 'string') headers['content-type'] = ct

  const upstreamRes = await fetch(upstream, { method, headers, ...(body !== undefined ? { body } : {}) })
  const text = await upstreamRes.text()
  res.statusCode = upstreamRes.status
  const upstreamCt = upstreamRes.headers.get('content-type')
  if (upstreamCt !== null) res.setHeader('content-type', upstreamCt)
  res.end(text)
}).listen(8902, '127.0.0.1')
```

> [!WARNING]
>
> Discord bot tokens use the `Bot <token>` auth scheme, **not** `Bearer`. Sending `Bearer …` returns `401 Unauthorized`.

### 2. Browser: wire it up

```ts theme={null}
import { DiscordVFS, MountMode, Workspace } from '@struktoai/mirage-browser'

const discord = new DiscordVFS({
  proxyUrl: '/api/discord',
  // For multi-tenant setups, return per-request auth headers:
  // getHeaders: async () => ({ Authorization: `Bearer ${userJwt}` }),
})

const ws = new Workspace({ '/discord': discord }, { mode: MountMode.READ })
const res = await ws.shell('ls /discord/')
console.log(res.stdoutText)
```

> [!WARNING]
>
> Calling `https://discord.com/api/v10/*` directly from a browser fails CORS. The proxy is mandatory, Discord does not set permissive CORS headers for the bot API.

## VFS.md

The [VFS.md](https://docs.mirage.strukto.ai/home/setup/vfs-md.md) an agent reads with Discord mounted at `/<path>`.

**Rendered**

### Virtual Filesystem

| Path | Description |
| - | - |
| `/<path>` | `discord`, read |

#### `/<path>` layout

```text theme={null}
/<path>
  <guild-name>__<guild-id>/
    channels/
      <channel-name>__<channel-id>/
        <yyyy-mm-dd>/
          chat.jsonl              # messages for that date
          files/                  # attachments shared that day (may be empty)
            <name>__<att-id>.<ext>  # cat to download bytes from Discord CDN
    members/
      <username>__<user-id>.json  # member profile
  Naming: guild, channel, member, attachment names are `<display-name>__<id>`.
  The display name keeps the original spelling from Discord (spaces,
  apostrophes, emoji); only `/` is replaced with `∕` (U+2215) so paths
  don't break. Quote names containing spaces in shell commands. Always
  ls the parent dir first to discover exact entry names (they include
  IDs).
  Without time bounds, bare channel listings show the latest 30 days
  ending at the newest message. Date globs can discover earlier days.
  With start_time/end_time, listings cover that scope; messages and their
  attachments outside it are absent, including through direct paths.
  Users and channel metadata remain available as context.
  grep and rg read the files they reach, so grep needs -r to search a
  directory; under a channel that reads every listed day, attachments
  included (downloaded from the CDN; binary ones such as JPG or PDF
  produce noise).
  Messages are JSONL; use jq to extract fields like .content, .author.username,
  .attachments.
  Time scope: start_time=unbounded (inclusive), end_time=unbounded (exclusive). Explicit paths and globs cannot escape this scope.
```

#### Supported Unix Commands for VFS

Unix commands work on each path as on a local filesystem, as far as the path supports them:

| Path | Supported |
| - | - |
| `/<path>` | Commands that read files (`ls`, `cat`, `grep`, `find`, `jq` and the rest). |

#### Limitations of Unix Commands for VFS

Where a path differs from a local filesystem:

| Path | Limitations |
| - | - |
| `/<path>` | Read only: no command changes files. `grep -r`, `find` and `du` stop after 1000 files; a glob matching more, `tar -c`, `cp -r` and `rm -r` are refused. |

Run `man` to list every command, and `man <cmd>` or `<cmd> --help` for one command's usage.

**Raw**

````markdown theme={null}
---
name: workspace-filesystem
description: The virtual filesystem layout of this workspace.
---

# Virtual Filesystem

| Path | Description |
| --- | --- |
| `/<path>` | `discord`, read |

## `/<path>` layout

```text
/<path>
  <guild-name>__<guild-id>/
    channels/
      <channel-name>__<channel-id>/
        <yyyy-mm-dd>/
          chat.jsonl              # messages for that date
          files/                  # attachments shared that day (may be empty)
            <name>__<att-id>.<ext>  # cat to download bytes from Discord CDN
    members/
      <username>__<user-id>.json  # member profile
  Naming: guild, channel, member, attachment names are `<display-name>__<id>`.
  The display name keeps the original spelling from Discord (spaces,
  apostrophes, emoji); only `/` is replaced with `∕` (U+2215) so paths
  don't break. Quote names containing spaces in shell commands. Always
  ls the parent dir first to discover exact entry names (they include
  IDs).
  Without time bounds, bare channel listings show the latest 30 days
  ending at the newest message. Date globs can discover earlier days.
  With start_time/end_time, listings cover that scope; messages and their
  attachments outside it are absent, including through direct paths.
  Users and channel metadata remain available as context.
  grep and rg read the files they reach, so grep needs -r to search a
  directory; under a channel that reads every listed day, attachments
  included (downloaded from the CDN; binary ones such as JPG or PDF
  produce noise).
  Messages are JSONL; use jq to extract fields like .content, .author.username,
  .attachments.
  Time scope: start_time=unbounded (inclusive), end_time=unbounded (exclusive). Explicit paths and globs cannot escape this scope.
```

## Supported Unix Commands for VFS

Unix commands work on each path as on a local filesystem, as far as the path supports them:

| Path | Supported |
| --- | --- |
| `/<path>` | Commands that read files (`ls`, `cat`, `grep`, `find`, `jq` and the rest). |

## Limitations of Unix Commands for VFS

Where a path differs from a local filesystem:

| Path | Limitations |
| --- | --- |
| `/<path>` | Read only: no command changes files. `grep -r`, `find` and `du` stop after 1000 files; a glob matching more, `tar -c`, `cp -r` and `rm -r` are refused. |

Run `man` to list every command, and `man <cmd>` or `<cmd> --help` for one command's usage.
````

```text theme={null}
/discord/
  <guild-name>__<guild-id>/
    channels/
      <channel-name>__<channel-id>/
        <yyyy-mm-dd>/
          chat.jsonl
          files/
            <stem>__<attachment-id>.<ext>
            ...
        ...
    members/
      <username>__<user-id>.json
      ...
```

The root lists one directory per guild the bot has access to. Each guild contains a `channels/` directory with text channels (types 0, 5, 15) and a `members/` directory with one `.json` file per member. Each channel directory contains day-partitioned **directories** for the 30 days leading up to the channel's last message, inactive channels show dates around their last activity, not today. Each day directory holds `chat.jsonl` (one JSON object per message) and a `files/` directory with that day's attachments, `cat`'ing a blob downloads it from the Discord CDN. Soft errors (403 missing permissions, 404 unknown channel, 429 rate limit) on a single day are swallowed so listings, `find`, and `grep` keep working across the rest of the tree.

Display names keep their original spelling from Discord (spaces, apostrophes, emoji are all preserved). Only `/` is replaced with `∕` (U+2215) so it cannot collide with a directory boundary. The Discord snowflake ID is appended after `__` (double underscore) on guild, channel, member, and attachment names so VFS-specific commands can extract it without an extra lookup, and so two same named entities never collide. Quote names containing spaces in shell commands.

## Shell commands

Every standard MIRAGE shell command works on the mounted Discord tree:

| Command | Notes |
| - | - |
| `ls` | List guilds, channels, members, dates, attachments |
| `cat` | Read `chat.jsonl`, member `.json`, or download an attachment |
| `head` / `tail` | Smart: uses messages API for file scope |
| `grep` / `rg` | Pattern search (file or directory level) |
| `jq` | Query JSON; runs once per line of a JSONL file (`-s` for all) |
| `wc` | Line/word/byte counts |
| `stat` | File metadata (name, size, type, ID via `extra`) |
| `find` | Recursive search with `-name`, `-maxdepth` |
| `tree` | Directory tree view |
| `pwd` / `cd` | Navigate the virtual tree |
| `echo` (glob) | Glob expansion via the derived `glob` op |

Acting on Discord (sending, editing, reactions, threads, polls,
member and guild info, search) goes through the
[discord CLI](https://docs.mirage.strukto.ai/typescript/cli/discord.md) when installed; the mounted
tree stays read-oriented.

## Troubleshooting

> **Empty `/discord/\<guild\>/members/` listing**
>
> Listing members requires the **Server Members Intent**. Enable it in the Discord Developer Portal under **Bot → Privileged Gateway Intents**. Without it, `members/` returns an empty directory and `discord members` fails.

> **Empty `content` field in JSONL messages**
>
> Reading message text requires the **Message Content Intent**. Enable it in the Discord Developer Portal under **Bot → Privileged Gateway Intents**. Without it, the `content` field on every message is empty.

> **`401 Unauthorized` from the proxy**
>
> Discord bot tokens use the `Authorization: Bot <token>` scheme, **not** `Bearer`. Double-check the proxy server attaches the correct prefix.

> **CORS error in browser**
>
> The browser cannot call `https://discord.com/api/v10/*` directly, Discord does not set permissive CORS headers. You must run the proxy server shown above (or your own equivalent) and point `proxyUrl` at it.

> **`rate_limited` from Discord**
>
> `DiscordVFS` uses an `IndexCacheStore` (default TTL 600s) to deduplicate guild / channel / member listings, but high-volume reads of `.jsonl` files can still hit Discord's per-route rate limits. The HTTP transport retries 429 responses honoring `retry_after` up to 3 times, repeated failures usually mean you should slow down or scope reads to specific channels.

## Integration

What Discord answers through its own API, so fewer files are read. [Integration](https://docs.mirage.strukto.ai/home/integration/overview.md) explains each command.

| Command | On Discord |
| - | - |
| `tail -f` | Each new message as it is posted |

## Examples

* [`examples/typescript/vfs/discord/discord.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/vfs/discord/discord.ts), shell commands against `/discord/` (`ls`, `cat`, `grep`, `jq`, `tree`, `find`, `cd`, glob).
* [`examples/typescript/vfs/discord/discord_vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/vfs/discord/discord_vfs.ts), `patchNodeFs(ws)` so native `node:fs` calls route through the workspace.
* [`examples/typescript/vfs/discord/discord_fuse.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/vfs/discord/discord_fuse.ts), FUSE-mount the workspace so external processes can browse `/discord/` as a real filesystem.
* [`examples/browser/vfs/discord/`](https://github.com/strukto-ai/mirage/tree/main/examples/browser/vfs/discord), proxy server + `@struktoai/mirage-browser` DiscordVFS demo.

See [Python Discord VFS](https://docs.mirage.strukto.ai/python/vfs/discord.md) for the equivalent Python wiring.

## Time scope

Set `start_time` and/or `end_time` in the mount's YAML `config` (or pass
`start_time`/`end_time` to Python; `startTime`/`endTime` to the TypeScript
constructor). Bounds are RFC3339 timestamps with an explicit timezone and
at most millisecond precision. Start is inclusive; end is exclusive.
An omitted or null bound is open. Start must precede end.

```yaml theme={null}
mounts:
  /history:
    vfs: discord
    mode: read
    config:
      token: "<bot-token>"
      start_time: "2026-06-01T10:00:00Z"
      end_time: "2026-06-02T00:00:00Z"
```

With a bound configured, channel listings cover the selected period rather
than the default recent window. Day directories use UTC. Messages are selected
by creation time, and attachments follow their parent messages. Direct paths,
`stat`, date globs, and `grep`/`rg` cannot escape the scope. Scoped searches scan
the filtered files. User/member
and channel/server metadata remain visible as context.

These settings apply to the VFS, not to separately installed account CLIs.
`mode: read` prevents mount writes; it does not freeze upstream edits or deletions.
Rebuild/remount the VFS to change its scope so cached listings and bytes are cleared.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.