---
title: Discord
icon: discord
description: Mount Discord guilds, channels, and members as a virtual filesystem from Node or the browser.
---

MIRAGE ships `DiscordResource` 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 (`discord-send-message`, `discord-add-reaction`, etc.).

## 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-message`)
   - `Add Reactions` (only if you'll use `discord-add-reaction`)
5. Open the generated URL in a browser and invite the bot to your guild.

```bash
# .env.development
DISCORD_BOT_TOKEN=...
```

## Node (server-side)

```bash
pnpm add @struktoai/mirage-node
```

```ts
import { DiscordResource, MountMode, Workspace } from '@struktoai/mirage-node'

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

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

## Browser

```bash
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.

### 1. Server: minimal proxy

```ts
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`.
</Warning>

### 2. Browser: wire it up

```ts
import { DiscordResource, MountMode, Workspace } from '@struktoai/mirage-browser'

const discord = new DiscordResource({
  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.execute('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.
</Warning>

## Filesystem layout

```text
/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 resource-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`   | Smart: uses search API for channel/guild scope (with fallback)   |
| `jq`            | Query JSON; use `.[]` prefix for JSONL files                     |
| `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 `resolve_glob`                                |

Resource-specific commands:

### `discord-send-message`

```bash
discord-send-message --channel_id 1256522563555819574 --text "Hello from MIRAGE"
discord-send-message --channel_id 1256522563555819574 --text "Reply" --message_id 1489887688978075769
```

| Option         | Required | Description                  |
| -------------- | -------- | ---------------------------- |
| `--channel_id` | yes      | Discord channel snowflake ID |
| `--text`       | yes      | Message text to send         |
| `--message_id` | no       | Message ID to reply to       |

### `discord-add-reaction`

```bash
discord-add-reaction --channel_id 1256522563555819574 --message_id 1489887688978075769 --reaction 👍
```

| Option         | Required | Description                  |
| -------------- | -------- | ---------------------------- |
| `--channel_id` | yes      | Discord channel snowflake ID |
| `--message_id` | yes      | Message snowflake ID         |
| `--reaction`   | yes      | Emoji (unicode or name)      |

### `discord-list-members`

```bash
discord-list-members --guild_id 1256522563555819574 --query "alice"
```

| Option       | Required | Description             |
| ------------ | -------- | ----------------------- |
| `--guild_id` | yes      | Discord guild snowflake |
| `--query`    | yes      | Username search query   |

Returns matching members as a JSON array. Requires the **Server Members Intent** to be enabled on the bot.

### `discord-get-server-info`

```bash
discord-get-server-info --guild_id 1256522563555819574
```

| Option       | Required | Description             |
| ------------ | -------- | ----------------------- |
| `--guild_id` | yes      | Discord guild snowflake |

Returns the guild object JSON (name, icon, member count, etc.).

## Troubleshooting

<AccordionGroup>
  <Accordion title="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-list-members` fails.
  </Accordion>
  <Accordion title="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.
  </Accordion>
  <Accordion title="`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.
  </Accordion>
  <Accordion title="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.
  </Accordion>
  <Accordion title="`rate_limited` from Discord">
    `DiscordResource` 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.
  </Accordion>
</AccordionGroup>

## Examples

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

See [Python Discord Resource](/python/resource/discord) for the equivalent Python wiring.
