Bot Commands
Overview
Cross-platform custom command system. Users create commands in the dashboard, assign them to platforms, and bots execute them with template variable support, cooldowns, and automation triggers.
Command Types
| Type | Description |
|---|---|
| Text | Fixed text response, one chat line — the dashboard editor turns typed or pasted line breaks into spaces |
| Template | Text with variables: {user}, {user_login}, {uptime}, {song}, {count}, {random:a,b,c} |
| Automation | Triggers an automation flow instead of sending text. Runs only where automation triggers are enabled, and never for a Twitch Shared Chat partner viewer |
Platform Assignment
Each command has a platforms JSON array. When creating, user selects which platforms the command is active on via checkboxes. Discord commands support both slash commands and prefix commands (configurable per command).
Permission Levels
Hierarchical - higher includes lower:
broadcaster > moderator > vip > subscriber > follower > everyone
Each bot maps platform-specific user data to this hierarchy.
Response Text
A command answers with one chat line. response_text containing a line break
(CR or LF) is rejected on create, update and override, with the same error on
GraphQL and REST: response_text must be a single line (no line breaks) (REST wraps
it as Validation error: …, like every bot-command validation error).
Independently of that check, every bot sanitises each outgoing message at its
platform transport (crates/lo-chat-outbound): line breaks and other control
characters become spaces, a leading / or . is removed so the text is never read
as a chat command, and the text is capped at the platform maximum (Twitch and Kick
500, YouTube 200, Discord 2000 characters). On Discord line breaks are kept and no
message can ping @everyone, @here, users or roles — only a feature post's
configured role is mentionable.
Cooldowns
Redis-based, per-command:
- Global:
lumio:cmd_cd:{account_id}:{command_name}:global(TTL = cooldown seconds) - Per-user:
lumio:cmd_cd:{account_id}:{command_name}:user:{user_id}(TTL = cooldown seconds)
Default: a new command (account or global) gets a 5 s global cooldown when
cooldown_global_secs is not sent. All tenants share one bot account per platform,
so an unthrottled command lets one chat burn the shared message budget. Set 0
explicitly to turn the cooldown off. The dashboard editor already defaults to 5 s.
Built-in commands of the Twitch, Kick and YouTube bots:
| Command | Reply | Source |
|---|---|---|
!song, !np | Now playing: Artist - Title, or No track is currently playing. | The Spotify track cache (lumio:spotify:current:{account_id}) the API's Spotify worker writes while a track plays; behind the widget:spotify_now_playing plan feature like {song} |
!uptime | Stream has been live for 2h 5m., or The stream is currently offline. | The API's channel-status cache for the bot's platform (lumio:channel_status:{account_id}:{platform}), the same as {uptime} |
!commands | The list above | - |
There is no built-in !queue or !skip. The built-ins read Redis directly; they call no API field. Every GraphQL document the bots send is validated against the API schema by apps/api/tests/zaf1913_bot_graphql_documents.rs.
The built-ins have a per-channel cooldown of 5 s per command, held in the bot's memory. On the
Twitch bot it is configurable (TWITCH_BOT__BOT__BUILTIN_COOLDOWN_SECS, 0 turns
it off); see Installation.
Template Variables
Extensible via VariableResolver trait in crates/lo-bot-commands/:
| Variable | Feature Gate | Description |
|---|---|---|
{user} | - | Display name of invoker |
{user_login} | - | Login / @handle of invoker |
{channel} | - | Channel/server name |
{platform} | - | Platform name |
{uptime} | - | Stream uptime on the command's platform, from the channel-status cache (same as !uptime) |
{song} | widget:spotify_now_playing | Currently playing Spotify track (same as !song) |
{count} | - | Command use count |
{random:a,b,c} | - | Random selection from list |
{user} is the display name; {user_login} is the login / @handle. On
Twitch the two differ when a chatter has a localized display name (e.g. {user} →
こさむん, {user_login} → pocom_2026). On Kick and YouTube the bot chat payload
carries no separate handle, so both resolve to the same value. {user} is left
unchanged — templates that already print {user} keep printing the display name.
Adding new variables: implement VariableResolver trait + register in VariableRegistry.
Global Commands & Overrides
- Global commands:
account_id IS NULL, created by admins, inherited by all accounts - Account commands:
account_idset, owned by specific account - Overrides: Accounts can override global commands (customize response, platforms, cooldowns) or disable them
- Shadowing: Account commands with same name as globals take priority
- Security: Overrides cannot make
min_roleless restrictive than global
Database
bot_commands- command definitions (global + account-scoped)bot_command_overrides- per-account overrides of global commandsbot_command_stats- usage statistics (Redis live counter, periodic DB flush)
Shared Crate: lo-bot-commands
Used by all four bots:
context.rs-CommandExecutionContext,UserRolebuiltins.rs- data behind the built-in!song/!uptimeand{song}/{uptime}, and the!commandslistregistry.rs-LoadedCommand,AccountCommands,execute_commandpermission.rs- Role hierarchy + checkingaccess.rs- the shared access gate (min_role, then the atomic cooldown slots) used by dynamic commands and extension command triggerscooldown.rs- Redis cooldown check/set/incrementtemplate.rs-{variable}parser with right-to-left replacementvariables.rs-VariableResolvertrait,VariableRegistry, 8 built-in resolvers
Bot Integration
All four bots (Twitch, YouTube, Kick, Discord):
- Load commands per account via
botCommandsForAccount(accountId, platform)system query - Store in-memory
CommandStore(HashMap<AccountId, AccountCommands>) - On chat message: check dynamic commands → access gate (permission → cooldown) → execute
command_syncRedis action triggers reload- Discord: also registers slash commands per guild (bulk overwrite, 200/day rate limit)
Twitch message processing. One IRC connection serves every tenant, so the Twitch bot never handles chat strictly one message at a time:
- Incoming IRC messages pass through a bounded ingest queue (4096). When it
is full the newest message is dropped and counted in
bot_chat_messages_dropped_total{platform="twitch",outcome="ingest_full"}. - Each channel gets its own ordered worker (queue of 128, same drop-newest
policy,
outcome="channel_queue_full"), and at most 32 handlers run at once across all channels. A slow channel (worker call, send throttle) no longer delays the others; messages within one channel keep their order. An idle channel's worker exits after 5 minutes and is re-created on the next message. - An account whose commands are not loaded yet is loaded lazily on its first command. If that load fails it is not retried for 60 seconds, so a failing API is not hit once per chat line.
API
| Type | Endpoint | Permission |
|---|---|---|
| GraphQL Query | botCommands: [BotCommand!]! | bot-commands:read |
| GraphQL Query | botCommand(id: ID!): BotCommand | bot-commands:read |
| GraphQL Mutation | createBotCommand(input: CreateBotCommandInput!) | bot-commands:create |
| GraphQL Mutation | updateBotCommand(id: ID!, input: UpdateBotCommandInput!) | bot-commands:edit |
| GraphQL Mutation | deleteBotCommand(id: ID!) | bot-commands:delete |
| GraphQL Mutation | overrideGlobalCommand(commandId: ID!, input: OverrideInput!) | bot-commands:edit |
| GraphQL Mutation | removeGlobalCommandOverride(commandId: ID!) | bot-commands:delete |
| System Query | botCommandsForAccount(accountId: ID!, platform: String!) | System key with bots:internal |
REST Endpoints
All paths live under /v1 and mirror the GraphQL mutations.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/bot-commands | bot-commands:read | List account commands (+ inherited globals) |
POST | /v1/bot-commands | bot-commands:create | Create an account command |
PUT | /v1/bot-commands/{id} | bot-commands:edit | Update a command |
DELETE | /v1/bot-commands/{id} | bot-commands:delete | Delete a command |
POST | /v1/bot-commands/{id}/override | bot-commands:edit | Override a global command for the account |
DELETE | /v1/bot-commands/{id}/override | bot-commands:delete | Remove the account's override of a global command |
Plan Limits
The plans.max_commands column caps how many commands an account may define. It
is read straight from the account's canonical plans row on every create - both
POST /v1/bot-commands and the createBotCommand mutation enforce the same cap
with the same PLAN_LIMIT_REACHED error (ZAF-1092). 0 means unlimited; if the
plan row cannot be resolved the cap fails open (unlimited). There is no
per-account override for this limit.
| Tier | Max Commands |
|---|---|
| Free | 25 |
| Pro | 200 |
Feature Gating
The whole Bot Commands surface is gated on the feature:bot_commands flag, in
addition to the bot-commands:* permissions. Every query and mutation
(botCommands, botCommand, createBotCommand, updateBotCommand,
deleteBotCommand, overrideGlobalCommand, removeGlobalCommandOverride on
GraphQL; GET/POST/PUT/DELETE /v1/bot-commands and the /override
endpoints on REST) enforces it on both protocols - GraphQL via
FeatureGuard::new("feature:bot_commands") chained before the permission guard,
REST via require_feature(..., "feature:bot_commands", ...). With the flag
disabled the API rejects the call directly (Feature 'feature:bot_commands' is not available, HTTP 403); the webapp route gate is UX only. The
fleet-only botCommandsForAccount query (used by bot workers; System key with bots:internal) is
intentionally not feature-gated.
RBAC Permissions
Account: bot-commands:read/create/edit/delete
Admin: bot-commands:read/create/edit/delete (via require_admin_permission)
Key Files
| Path | Description |
|---|---|
crates/lo-bot-commands/ | Shared command execution crate (context, registry, permission, cooldown, template, variables) |
apps/api/src/db/bot_commands.rs | Database operations |
apps/api/src/graphql/bot_commands.rs | GraphQL queries and mutations |
apps/api/src/routes/bot_commands_crud.rs | REST endpoints |
See Also
- Bot Control - Bot pause/resume/reconnect dashboard
- Bot Connections - Custom bot identity OAuth flow
- Bot Modules - Chat moderation modules
- Automations - Automation flows triggered by commands