Set a Discord custom rich presence from Python. You give it a token, it keeps a gateway connection alive and pushes whatever presence you want.
Heads up: driving a user account over the gateway is self-botting, which Discord's Terms of Service prohibit and which can get an account actioned. The library also works with a bot token, which is the supported route.
pip3 install -r requirements.txtOnly dependency is websockets>=12. Python 3.9+.
from customrpc import RichPresence
with RichPresence("YOUR_TOKEN") as rpc:
print("Logged in as", rpc.username)
rpc.set(
name="Visual Studio Code",
details="Editing gateway.py",
state="Workspace: rpc-me",
)
rpc.run_forever() # Ctrl-C to stop; presence is cleared on exitNon-blocking version:
rpc = RichPresence(token).start() # returns immediately
rpc.wait_until_ready()
rpc.set(name="My App")
...
rpc.close()export DISCORD_TOKEN='your token'
python3 -m customrpc --name "My App" --details "Line one" --state "Line two" --elapsedpython3 -m customrpc --name "A Song" --type listening --details Song --state Artist --status dnd--help lists every flag. With no --token and no $DISCORD_TOKEN it prompts
(hidden input), which keeps the token out of your shell history. --json file
loads the presence fields from a JSON file, and --duration N exits after N
seconds instead of running until Ctrl-C.
| option | default | meaning |
|---|---|---|
status |
"online" |
online, idle, dnd, invisible, offline |
auto_reconnect |
True |
reconnect on drops, with backoff |
max_reconnect_attempts |
None |
give up after N tries (None = never) |
reconnect_base_delay |
5.0 |
first backoff step, doubling to a 16× cap |
clear_on_close |
True |
wipe the presence when disconnecting |
auto_external_assets |
True |
proxy image URLs through Discord (see below) |
on_ready / on_state / on_error |
None |
callbacks |
gateway_url / api_base |
Discord | override for testing |
Methods: connect(timeout), start(), wait_until_ready(), set(**fields),
set_presence(presence), update(**fields), clear(), set_status(status),
run_forever(), close(). Properties: connected, user, username,
latency_ms, presence.
update() changes only the fields you name and keeps the rest:
rpc.set(name="Game", details="Level 1", large_image="icon")
rpc.update(details="Level 2") # name and image are preservedEvery field, all optional but name:
from customrpc import Presence, ActivityType, Status
presence = Presence(
name="rpc-me", # the bold first line
details="Testing customrpc", # second line
state="Round 1 of 10", # third line
activity_type=ActivityType.PLAYING, # PLAYING/STREAMING/LISTENING/WATCHING/COMPETING
status=Status.DND,
application_id="123...", # required to use image URLs
large_image="python", large_text="Large tooltip",
small_image="discord", small_text="Small tooltip",
start=Presence.elapsed_since_now(), # "01:23 elapsed"
end=Presence.remaining(300), # "05:00 left"
party_size=1, party_max=10, # "(1 of 10)"
party_id="lobby-7",
join_secret="...",
buttons=[("Source", "https://github.com/..."), ("Docs", "https://...")],
streaming_url="https://twitch.tv/name", # for ActivityType.STREAMING
)
rpc.set_presence(presence)Timestamps accept a datetime, unix seconds, or unix milliseconds.
Presence.copy(**changes) returns a modified copy; Presence.from_dict()
builds one from a dict and rejects unknown keys.
Everything is validated before anything is sent, so a bad presence raises
PresenceValidationError rather than being silently dropped by Discord:
empty name, activity type 4 (custom status, not settable over the gateway),
more than two buttons, non-http button URLs, labels over 32 characters,
half-specified parties, party_size > party_max, start after end,
non-Twitch/YouTube streaming URLs.
Discord does not render arbitrary image URLs. It renders asset keys uploaded to
an application, or proxied mp:external/... keys. If you pass a http(s) URL
as large_image/small_image and set application_id, this library
exchanges it for a proxied key automatically (cached, so repeat updates are
free). Create an application ID at
https://discord.com/developers/applications — New Application, copy the ID;
no bot user and no code needed.
The app does not have to be yours for URL proxying — the request is authenticated with your token and the app ID only namespaces the proxy entry, so any visible ID works. Two caveats: that endpoint is undocumented client-only API, so the behaviour isn't a contract, and borrowing an app you don't control means your images break if it disappears.
Ownership does matter for uploaded asset keys (large_image="my_cover").
Those live under an application's Rich Presence → Art Assets, you can only
upload to an app you own, and the activity's application_id has to be that
same app or Discord has nothing to resolve the key against.
If you set a URL with no application_id, you get a warning and the URL is sent
unchanged, which Discord will ignore.
Two shapes exist in the wild. button_style="objects" (default, what the
reference Android app sends) emits buttons: [{label, url}];
button_style="metadata" emits buttons: ["label"] plus
metadata.button_urls, which is what the official desktop client sends.
If your buttons don't appear, try the other one — and note that Discord hides
rich-presence buttons on your own profile, so check from another account.
CustomRPCError is the base. AuthenticationError (bad/expired token, close
code 4004, or op 9 before READY — never retried), GatewayError,
NotConnectedError, PresenceValidationError, ConnectionTimeout.
DiscordGateway is the raw connection if you want to drive op codes yourself:
connect(wait=), send(payload), send_presence(payload), close(), plus
latency, user, session_id, last_error.
./run_tests.shor python3 -m unittest discover -s tests -t . -v. They are plain
unittest.TestCases, so pytest collects them as-is if you prefer that
runner. 88 tests, about 12 seconds:
| file | covers |
|---|---|
tests/test_presence.py |
payload construction and every validation rule |
tests/test_gateway.py |
handshake, heartbeat/ACK, op 7 reconnect, op 9, close 4004, dead heartbeats, unreachable server |
tests/test_client.py |
RichPresence lifecycle, set/update/clear/status, auth failures |
tests/test_assets.py |
image-URL proxying, caching, API errors |
tests/test_cli.py |
argument parsing, token resolution, a full CLI run |
tests/fake_gateway.py is a local websocket server that speaks the v10 gateway
protocol, so the connection logic is exercised for real (handshake, heartbeats,
reconnects) without touching Discord. Its mode switch simulates the failure
paths: bad_token, invalid_session, reconnect_once, no_ack, close_4004.
export DISCORD_TOKEN='your token'
export DISCORD_APP_ID='your app id' # optional, for the image steps
python3 scripts/live_test.pyWalks through 11 steps against the real gateway — plain card, images, elapsed
timer, countdown, buttons, party counter, listening/watching types, a partial
update, status changes, clear — printing what you should see for each one so
you can check it against a Discord client. --list shows the steps, --step N
runs just one (repeatable), --hold N changes how long each step lingers.
The token is read from the environment or a hidden prompt, never printed and never written to disk.
customrpc/
__init__.py public API
client.py RichPresence, the high level interface
gateway.py websocket, op codes, heartbeat, reconnect
presence.py Presence dataclass and payload building
assets.py image URL -> mp:external key
errors.py exception types
__main__.py python -m customrpc
tests/ offline suite + fake gateway server
examples/ basic.py, advanced.py
scripts/ live_test.py
Discord does not offer a "copy my token" button, on purpose. Anyone holding it can act as you completely, so treat it like a password: keep it in an environment variable or a secrets manager, never in a committed file, and change your password to invalidate it if it leaks.