Important
This application is meant to be an example with real functionality to allow developers to better understand our API. This repo is not actively maintained, and has not been tested in any sort of real production use.
A reference client for the JoystickTV bot API, in Python. It's the companion
to public-bot-example-swift
(the native macOS example) and covers the same API surface as a small CLI:
- Public-client OAuth — authorization code + PKCE over a loopback redirect (RFC 7636 / RFC 8252), no client secret.
- Token lifecycle — refresh (with rotation) and RFC 7009 revocation.
- REST — identity, stream status, chat messages, moderation, banned words.
- Realtime gateway — the ActionCable stream, decoding both the legacy v1
envelope and the v2 envelope (a flat
type+ objectdata).
pip install -e .Register a Public bot in your JoystickTV account and set its redirect URL to
http://127.0.0.1/callback (a loopback host — the example binds a random port at
runtime). Then:
export JOYSTICK_CLIENT_ID=<your bot's OAuth client id>
# Optional — defaults to https://joystick.tv:
export JOYSTICK_BASE_URL=https://joystick.tv
# Optional — gateway envelope version, "v2" (default) or "v1":
export JOYSTICK_EVENT_VERSION=v2joystick-example login # authorize via PKCE (opens a browser), save tokens
joystick-example refresh | logout | introspect
joystick-example whoami # bot identity + granted scopes
joystick-example stream show # live state / title / viewer count
joystick-example stream set --title "…" --tag foo --tag bar
joystick-example say "gg" # post a chat message as the bot
joystick-example whisper <username> "…" # whisper a user
joystick-example connect # stream realtime gateway events until Ctrl-C
joystick-example followers | subscribers # username lists
joystick-example moderators list | add <username> | remove <username>
joystick-example mod delete|mute|ban <message-id> | unban <username>
joystick-example words list | add <word> | remove <word>(Or run without installing: python -m joystick_example <command>.)
Each command maps to one endpoint / scope, so the source doubles as documentation:
| Command | Route | Scope |
|---|---|---|
whoami |
GET /api/v1/me/identity |
identity:read |
stream show / set |
GET / PUT /api/v1/me/stream |
stream:read / stream:manage |
say |
POST /api/v1/chat/messages |
chat:write |
whisper |
POST /api/v1/chat/whispers |
chat:whisper:write |
mod delete/mute/ban / unban |
…/chat/messages/:id… / …/chat/users/:u/ban |
chat:moderate |
followers / subscribers |
GET /api/v1/{followers,subscribers} |
followers:read / subscribers:read |
moderators list/add/remove |
GET/POST/DELETE /api/v1/moderators |
moderators:read / moderators:manage |
words |
…/api/v1/banned-words |
chat:moderate |
introspect |
POST /api/oauth/introspect |
— |
connect |
wss://<host>/cable (GatewayChannel) |
— |
connect opens the ActionCable socket, authenticating with the bot's bearer
JWT as the ?token= query param (the same access token the REST calls use —
no secret, which is the point of a public client). It subscribes to
GatewayChannel, and — unless you pass --event-version v1 — opts into the
v2 event envelope:
joystick_example/events.py decodes both v1 and v2 into a small GatewayEvent,
so a deploy that predates v2 (and ignores the subscribe param) still works — you
just receive v1 envelopes. Token refresh is proactive: a long-running connect
checks the JWT exp and rotates the token before it lapses.
pip install -e ".[dev]"
python -m pytestThe event tests run against real captured v1 and v2 frames, so they pin the decoder to the actual wire format.
{ "v": 2, "type": "tipped", "id": "…", "channel_id": "…", "occurred_at": "2026-07-07T14:52:31Z", "text": "BusTester tipped 50 tokens", "data": { "who": "BusTester", "how_much": 50, "tip_menu_item": "Hydrate" } }