Technical Guide

Putting Your Discord Bot in a VR Headset

The headset icon next to a bot's name doesn’t appear in any official documentation, and community documentation even claims it’s out of reach. That’s false: just three lines of properties are enough. Here’s how, and why it works.

discord.py Gateway Live Tested 10 minutes

What Discord Actually Displays

Next to a user’s name, Discord can show a small platform icon: a phone, a controller, or a VR headset. Many developers assume Discord detects this platform automatically. That’s not the case.

Almost everything Discord displays about a session, is sent by the client. The server simply relays it to other members. The status comes from the status field of the presence, the purple dot for streaming comes from the activity type, and the platform icon comes from a field most libraries never expose: the properties field of the IDENTIFY packet, sent once when the gateway connection opens.

discord.py hardcodes "discord.py" there. As a result, a bot always appears as a desktop client, no matter what you do afterward with change_presence().

Remember the distinction: presence (status, activity) can change anytime. Properties only go out with IDENTIFY. These are two separate layers.

Why the Docs Say It’s Impossible

The reverse-engineered API documentation (the one everyone checks when the official docs are silent) lists the vr status as accessible only via « headless sessions », created with an OAuth token carrying the activities.write scope — a private scope reserved for Meta. The logical conclusion: unreachable for a bot.

Except this mechanism changed. Around February 2026, Discord reclassified Android clients running on Meta headsets: they no longer appear in the mobile slot but in the vr slot. The native Discord app on Quest, however, only arrived in June 2026 — later. The mechanism is therefore recent, absent from the public API, and community documentation still describes the old state.

In practice, classification happens based on the string sent in properties. A client announcing itself as a Discord running in VR on Android is placed in the VR slot. A bot can send exactly the same thing.

The lesson applies beyond this example: for Discord’s undocumented mechanisms, a 10-minute test beats an undated documentation page. What was true last year may not be anymore.

Working Values

Each row corresponds to the content of the properties field sent in IDENTIFY. Tested one by one in real conditions, observing the rendering on a real Discord client.

Icon Obtained os browser / device
None (desktop client) win32, linux Discord Client
Green phone android Discord Android
VR Headset android Discord VR
None Any unrecognized string (Discord Quest, etc.)
An unrecognized string triggers no error: the connection opens normally, nothing happens. No message, no exception, no icon. That’s what makes the search painful — you have to check the client on every attempt.

The Code

discord.py doesn’t expose properties. So we override the DiscordWebSocket.identify method with our own. The body is a direct copy of the original — only the properties block changes.

presence.py
import discord
import discord.gateway

# Les properties envoyées dans l'IDENTIFY : c'est ce trio qui
# fait ranger la session dans le slot "vr" côté Discord.
PROPERTIES_VR = {
    "os": "android",
    "browser": "Discord VR",
    "device": "Discord VR",
}


def installer_patch_identify():
    """Remplace identify() pour injecter nos properties.

    Copie conforme de l'originale de discord.py 2.7.1 : si tu es
    sur une autre version, recopie le corps de TA version et ne
    change que le bloc properties. À appeler avant bot.run().
    """

    async def identify(self):
        payload = {
            "op": self.IDENTIFY,
            "d": {
                "token": self.token,
                "properties": PROPERTIES_VR,   # <-- la seule ligne qui change
                "compress": True,
                "large_threshold": 250,
            },
        }

        if self.shard_id is not None and self.shard_count is not None:
            payload["d"]["shard"] = [self.shard_id, self.shard_count]

        state = self._connection
        if state._activity is not None or state._status is not None:
            payload["d"]["presence"] = {
                "status": state._status,
                "game": state._activity,
                "since": 0,
                "afk": False,
            }

        if state._intents is not None:
            payload["d"]["intents"] = state._intents.value

        await self.call_hooks("before_identify", self.shard_id,
                              initial=self._initial_identify)
        await self.send_as_json(payload)

    discord.gateway.DiscordWebSocket.identify = identify

On startup, there’s nothing more to do than call the patch before connecting the bot:

bot.py
from presence import installer_patch_identify

installer_patch_identify()   # AVANT bot.run(), sinon l'IDENTIFY est déjà parti
bot.run(TOKEN)
The patch modifies discord.py’s internal API (_connection, _intents). That’s the trade-off, and it means rechecking the code after every library update. In return, the patch has no side effects: it doesn’t add anything to the payload, it just replaces a hardcoded value.

The Three Pitfalls

TRAP 01

Restart Required

IDENTIFY only sends on connection open. A simple reconnect isn’t enough: discord.py then sends a RESUME, not an IDENTIFY. Changing platforms requires restarting the process.

TRAP 02

The Bot Is Blind

Discord never sends a bot its own presence: zero PRESENCE_UPDATE, even with the presences intent, even from a second session with the same token. No script can verify its own display — you have to check the client.

TRAP 03

Failure Is Silent

An unrecognized value triggers nothing. Same for the purple stream dot: a URL outside twitch.tv / youtube.com silently falls back to « playing… » in green.

What About the Controller Icon?

The controller icon question always comes next. It corresponds to the embedded slot, the one for activities launched within Discord itself. No properties value tested triggered it, and the empirical argument is solid: console integration is far older than VR, thousands of people have searched, no one has published a method. If the slot were reachable from the client side, it would be known.

The difference with VR lies precisely in the February 2026 reclassification: Discord places ordinary Android sessions there, ones any client — and thus any bot — can mimic. Nothing equivalent exists for embedded.

Avoid: workarounds that impersonate Meta’s client ID via an OAuth flow on a user account. That’s self-botting, against the terms of service, punishable by account bans — and has no effect on a bot. The method here uses only a normal bot token and a field the client sends anyway.

Test, Don’t Trust the Docs Blindly

This icon was labeled « impossible » by the only documentation mentioning it. It took trying each string one by one and checking the client for the headset to appear. If you find other reachable slots, share them: that’s exactly the kind of discovery that doesn’t spread well elsewhere.

Discuss on DevHub