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.
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().
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.
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.) |
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.
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:
from presence import installer_patch_identify installer_patch_identify() # AVANT bot.run(), sinon l'IDENTIFY est déjà parti bot.run(TOKEN)
_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
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.
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.
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.
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