Drive an All-Chat account from Stream Deck hardware on Linux: send a preset chat message to every connected platform at once, and start, close, lock, resolve or cancel polls and predictions without alt-tabbing out of a stream.
This is a plugin for StreamController, the open-source Linux Stream Deck
application. StreamController has its own Python plugin API and does not
load Elgato plugins, which is why this exists separately from
allchat-streamdeck — that
one is TypeScript against Elgato's Node SDK, for Windows and macOS. The two are
deliberate mirrors of each other: same actions, same endpoints, same failure
states. ADR-0049 in the main All-Chat repository records the reasoning, and
every action module here carries a KEEP IN SYNC pointer at its counterpart.
Self-contained: standard library only, nothing to install.
The plugin is free. Two of its buttons need All-Chat premium, and that is deliberate product behaviour, not a bug.
The server gates starting a poll or a prediction behind the premium
engagement feature. Everything that finishes or unwinds a round is free:
| Action | Route | Premium? |
|---|---|---|
| Send chat message | POST /api/v1/auth/chat/send |
free |
| Poll → start | POST …/engagement/overlays/:id/polls |
premium |
| Poll → close | POST …/polls/:pollId/close |
free |
| Prediction → start | POST …/engagement/overlays/:id/predictions |
premium |
| Prediction → lock | POST …/predictions/:pid/lock |
free |
| Prediction → resolve | POST …/predictions/:pid/resolve |
free |
| Prediction → cancel | POST …/predictions/:pid/cancel |
free |
So on a free account the two start keys return HTTP 403 — and that is the server working correctly, not the plugin failing. The plugin treats that 403 as its own state: the key shows ⭐ Premium, and the log carries a specific message saying that starting polls and predictions is a premium feature, that the close/lock/resolve/cancel keys still work, and that you can upgrade at https://allch.at/upgrade. It is never reported as a generic "request failed", and it is logged at INFO rather than ERROR, because nothing went wrong.
The asymmetry is intentional: if you started a round while premium, or from the web dashboard, you can always end it. A free account is never left holding a poll it cannot close, or a prediction whose stakes it cannot refund.
The plugin does not attempt to work around the gate and contains no server-side
code; the gate lives in All-Chat's services/engagement-service.
Sending a user to the billing page when their real problem is a mis-scoped token
would be worse than a generic error, so the plugin branches on the server's own
error field:
| Server says | Key shows | What actually fixes it |
|---|---|---|
Premium feature required |
⭐ Premium | Upgrade the account. |
insufficient token scope |
⚠ Error | Mint a new token with the right scope. Upgrading would not help. |
| anything else (e.g. an overlay you do not own) | ⚠ Error | Check the overlay ID. |
HTTP 401 is separate again: the token is expired, revoked or mistyped — mint a fresh one and re-paste it. It never points at the upgrade page.
Requires StreamController 1.5.0-beta.8 or newer, and Python 3.11+ (whatever StreamController itself runs on — the plugin adds no interpreter requirement of its own).
The plugin folder must be named after the plugin ID, com_allchat_streamcontroller.
Each release
has a zip that already contains a folder with that name, so unzipping it into the
plugins directory below is the same as the copy.
Flatpak install of StreamController (the usual case):
git clone https://github.com/caesarakalaeii/allchat-streamcontroller.git
mkdir -p ~/.var/app/com.core447.StreamController/data/plugins
cp -r allchat-streamcontroller \
~/.var/app/com.core447.StreamController/data/plugins/com_allchat_streamcontrollerNative / source install:
mkdir -p ~/.local/share/StreamController/plugins
cp -r allchat-streamcontroller \
~/.local/share/StreamController/plugins/com_allchat_streamcontrollerThen restart StreamController. The three actions appear in the action picker under All-Chat.
Developing against a checkout? Symlink instead of copying, so edits land without re-copying — StreamController re-imports plugins on restart:
ln -s "$PWD" \
~/.var/app/com.core447.StreamController/data/plugins/com_allchat_streamcontrollerThere are none. requirements.txt is intentionally empty of
packages and explains why: a StreamController plugin is imported into the host
application's own Python process, so every dependency is a chance to break an app
we do not control. The plugin uses urllib.request, json and urllib.parse
from the standard library.
You do not need to pip install anything.
Two routes, and the first is the one almost everyone should take.
Add an All-Chat action, open its settings, and press Link.
The plugin binds a short-lived listener on 127.0.0.1 — never 0.0.0.0 —
generates a PKCE S256 challenge, and opens your browser at an approve screen. You
pick which overlay this action may drive, confirm what it may do, and press
Approve; All-Chat redirects to the local listener with a one-time code, the plugin
trades that code plus its PKCE verifier for a device token, and the socket
closes immediately. Nothing is typed and nothing is pasted, which is the point: a
secret you never see cannot be read aloud on stream or caught by the camera. See
ADR-0049.
A linked device differs from a pasted token in three ways that matter:
- It is bound to one overlay, chosen when you approved it. A request for any other overlay is refused server-side, so a compromised deck cannot reach the rest of your account.
- It expires if unused — 90 days, pushed forward on every request, so a deck in daily service never lapses while one on a machine you stopped using does.
- You never handle the secret. It goes machine-to-machine.
Manage and revoke paired devices at https://allch.at/settings/devices. Revocation takes effect on that device's next request.
Linking needs the plugin and your browser on the same machine. When they are
not — a deck driving a second PC, a Flatpak sandbox that will not let a port be
bound, a headless host — the plugin falls back on its own and shows an
eight-character code like KRPT-8W4M. Open https://allch.at/link on any device
you are signed in on, type the code, approve. The plugin is polling and finishes
by itself.
The code lives ten minutes and tolerates five wrong attempts. Its alphabet has no
0/O or 1/I/l in it, because you are going to read it off a screen.
Still supported, and the right answer for a headless capture box or a machine you only reach over SSH.
- Sign in at https://allch.at.
- Go to Settings → API tokens (https://allch.at/settings/api-tokens).
- Create a token, give it a name that identifies the machine (
stream-pc-deck), and grant the scopes you need:chat:write— for the Send chat message action.engagement:write— for the Poll control and Prediction control actions.
- Copy the token. It starts with
allchat_pat_and is shown once — only a SHA-256 of it is stored server-side, so it cannot be shown again. - Paste it into the API token field of each All-Chat action.
Lost it, or pasted it on stream by accident? Revoke it on the same page and mint a replacement; nothing else about your account is affected.
Unlike a linked device, a pasted token is not overlay-bound and has no expiry unless you set one — which is why it works where linking cannot, and why linking is the better choice when it is available.
- The credential is sent only as
Authorization: Bearer <credential>, and the server switches on the prefix (allchat_dev_from linking,allchat_pat_from a paste). It is never placed in a URL and never written to a log line — there is a test that asserts this for every failure path, and the loopback listener's request logging is silenced because the request line carries the one-time code. - It is stored by StreamController in that action's settings, per action, so one deck can drive two accounts.
- The plugin checks the prefix locally, purely to catch a pasted session JWT before a pointless round-trip. Only the server can say whether a credential is actually valid, in scope, or revoked.
- Grant the narrowest scopes that work. A chat-send key does not need
engagement:write.
All three share the same connection settings: Link (or a pasted API
token), and an optional Base URL for self-hosters (blank means https://allch.at, which is the production host,
confirmed as FRONTEND_URL in deployments/k8s/base/configmap.yaml).
| Setting | Meaning |
|---|---|
message |
The text to send. Max 500 characters. |
platform |
all (default), twitch, youtube or kick. |
all fans the message out to every connected platform in one press — the reason
the action exists. Free; needs chat:write.
TikTok and Discord are not send targets. All-Chat reads their chat onto your
overlay, but TikTok publishes no API for posting into a live chat and a Discord
source is a one-way relay, so neither can be posted to and neither is included in
all. A key still set to tiktok from an earlier version says so when pressed
instead of failing silently.
| Setting | Meaning |
|---|---|
mode |
start (premium) or close (free). |
overlay_id |
The overlay the poll belongs to. Required. |
question |
start only. |
options |
start only. 2–5, one per line (or comma-separated). |
duration_seconds |
start only. Blank/0 = run until closed by hand. |
poll_id |
close only, optional — see below. |
Leave poll_id blank on a close key. The plugin remembers the ID of a poll it
started, and otherwise asks the overlay for its active poll, so the usual setup
is a start key and a close key that both carry only the overlay ID.
| Setting | Meaning |
|---|---|
mode |
start (premium), or lock / resolve / cancel (free). |
overlay_id |
Required. |
title |
start only. |
outcomes |
start only. 2–10, one per line. |
auto_lock_seconds |
start only. Blank/0 = lock by hand. |
prediction_id |
Optional on the free modes — same fallback as polls. |
winning_outcome_id |
Required by resolve. |
For resolve, the usual layout is one key per possible outcome, each with that
outcome's ID, so declaring a winner is a single press.
The plugin imports StreamController's PluginBase, ActionBase and
ActionHolder at runtime. Those live inside the host application and are not on
PyPI, so actions/host.py imports them defensively and falls
back to small stand-ins when they are absent. That is what lets everything below
run on a machine with no Stream Deck attached.
# Syntax gate — the same check CI runs.
python3 -m compileall -q .
# Unit tests: the premium/scope/401 split, every route as a literal string,
# input validation, and that no failure path can log a token.
python3 -m unittest discover -s tests -t .Any Python 3.11 or newer runs these; nothing needs installing.
The tests stub urllib.request.urlopen, so they never touch the network and need
no All-Chat account. What they mainly protect is the premium boundary: there is a
test asserting that exactly one error kind maps to the premium key state, so
a future refactor cannot quietly turn the feature's only advertisement into a
generic error, nor start telling mis-scoped users to buy something that will not
help them.
.github/workflows/ci.yml runs the two commands above on every PR, validates
manifest.json / about.json / attribution.json (including that the two
versions and ids agree, and that the licence is still GPL-3.0-or-later), and
uploads an installable zip as a run artifact. Unzip it into the plugins directory
named under Install to test a branch.
The zip is a staged allowlist, not the whole repository: main.py, the three
JSON files, LICENSE, requirements.txt, README.md, actions/ and allchat/.
tests/ is deliberately left out — it is mock plumbing that a user's
StreamController would import-scan for nothing.
Bump version in both manifest.json and about.json, merge that, then push an
annotated tag:
git tag -a v0.1.2 -m "What changed, in a few lines for the release page"
git push origin v0.1.2The tag runs the full CI. If everything passes and the tag matches
manifest.json, the release job publishes a GitHub release with the zip
attached and the tag message as its notes. The store entry is a separate step: a
PR to StreamController-Store that moves the pinned hash to the new commit.
The same workflow runs scripts/check-plugin-parity.py from the All-Chat
repository against this checkout and
allchat-streamdeck. It
fails when a button, a user-visible constant, a linking timeout or a
KEEP IN SYNC pointer exists on one plugin and not the other.
A change that has to land on both plugins (a new button, a renamed constant)
therefore spans repositories. Push a branch with the same name to each repo
that changes: the parity job checks out that branch from the other two
repositories when it exists, and main otherwise, so the three branches are
checked together before any of them merges. To run it locally, clone all three
repositories side by side and run
python3 ../all-chat/scripts/check-plugin-parity.py from here.
allchat-streamcontroller/
├── main.py # PluginBase subclass; registers three ActionHolders
├── manifest.json # plugin ID, version, host compatibility
├── about.json # author, licence, links
├── attribution.json # licence and credits, in the format the store reads
├── LICENSE # GPL-3.0
├── store/Thumbnail.png # store banner
├── requirements.txt # intentionally empty; explains why
├── allchat/ # host-independent client (no StreamController imports)
│ ├── settings.py # defaults, validation, redaction
│ ├── errors.py # error taxonomy, incl. the three-way 403 split
│ ├── client.py # the one urllib request function
│ ├── linking.py # PKCE loopback + pairing-code fallback (ADR-0049)
│ └── api.py # endpoints, with the premium flag beside each path
├── actions/
│ ├── host.py # real host classes, or offline stand-ins
│ ├── base.py # error kind -> key state
│ ├── send_message.py
│ ├── poll_control.py
│ └── prediction_control.py
└── tests/
allchat/ deliberately imports nothing from StreamController, so the interesting
logic is testable in isolation.
allchat/linking.py is the counterpart of
allchat-streamdeck/src/allchat/linking.ts, and the two are compared by the
All-Chat repository's scripts/check-plugin-parity.py: the wire constants, the three timeouts, the
requested scope set, and the rule that neither may ever bind 0.0.0.0.
GPL-3.0-or-later, the licence of the StreamController host the plugin is loaded
into; see LICENSE. The plugin was split out of the AGPL-3.0-or-later
All-Chat repository, talks to All-Chat only over its public HTTP API and contains
none of its server code.
| Key shows | Meaning |
|---|---|
| ⭐ Premium | Expected on a free account when starting a poll/prediction. Everything else still works. Upgrade at https://allch.at/upgrade. |
| ⚠ Error, log says token scope | The credential lacks chat:write or engagement:write. Link again and grant it, or mint a new token; upgrading will not help. |
| ⚠ Error, log says HTTP 401 | Credential expired, revoked or mistyped. A linked device lapses after 90 days unused — press Link again. A pasted token gets re-minted. |
| ⚠ Error, device not paired with this overlay | A linked device is bound to the overlay you approved it against, and a request for another is refused. Revoke it at https://allch.at/settings/devices and link again. |
| ⚠ Error, log says HTTP 404 | Wrong overlay ID, or the round already ended. |
| ⚠ Error, log says HTTP 409 | There is already an active poll/prediction on that overlay. |
| ⚠ Error, log says HTTP 429 | Rate limited — a chat send fans out to every platform, and per-platform limits bind fastest. Press more slowly. |
| ⚠ Error, could not reach | Network, DNS or TLS. Check the Base URL if you self-host. |
| Link shows a pairing code | Expected: the plugin could not bind a local port or open a browser. Enter the code at https://allch.at/link. |
| Link fails outright | Neither path worked — a sandbox with no networking to 127.0.0.1 and no browser. Paste a personal access token instead. |
The plugin's messages appear in StreamController's log. None of them ever contains your credential — at most its public prefix, so a log line can say which kind is configured.