Bot Modules
Bot modules are extensions that handle chat messages, platform events, and timed actions. All bot modules -- built-in and community -- run as handler-based server functions inside V8 isolate sandboxes via the Bot Module Worker.
There is no native Rust BotModule trait or module registry crate. The built-in modules -- Link Protection, Spam Protection, Word Filter, Timed Messages -- live in extensions/system/ and run through the same V8 execution model as third-party bot modules. See Extensions for the full list.
Architecture
Chat message
│
Platform bot (twitch / youtube / kick / discord)
├─ Holds the account's compiled trigger set in memory
├─ Matches the message against commands / keywords / regex patterns
├─ command match → min_role + cooldown gate → sync HTTP POST /v1/execute
│ on the Worker (500 ms deadline); the bot posts the reply,
│ then the handler's actions
├─ keyword/pattern → Redis publish to lumio:botmod:msg:{platform}:{account_id}
└─ has_moderate install (twitch / kick / youtube only) → spawned
POST /v1/execute type "moderate" (hard 500 ms, fail-open); the bot posts
the decision's actions
Stream event → API publishes lumio:events:{account_id}
Timer slot → Worker's own TimerEngine
│
Bot Module Worker
├─ /v1/execute (commands, moderation), the chat-trigger listener
│ (keyword/pattern), the event-trigger listener and the timer scheduler
│ all run through MessageRouter
└─ Executes the handler in a V8 isolate
├─ Handler receives ctx (auth, config, user, db, cache, secrets, fetch, defer)
├─ Returns { reply?, actions[] }
├─ Command reply → HTTP response to the bot;
│ keyword/pattern/event/timer reply → lumio:bot:action:{platform}:{account_id}
└─ actions[] → POST /v1/internal/bot-modules/{install_id}/actions on the API
(after the reply, at most once) → shared extension action dispatcher
Bot Module Worker
The Bot Module Worker (apps/bot-module-worker/, library in crates/lo-bot-module-worker/) is a standalone Actix Web service on port 8090. The Worker does no chat trigger matching -- the bots own the chat trigger set and the matching; the Worker runs the handler it is told to run, over HTTP for commands and moderation and over Redis for keyword/pattern triggers. Stream events and timers do not pass through the bots: the Worker matches events against its install registry and schedules timers itself.
Chat-trigger listener (crates/lo-bot-module-worker/src/chat_triggers.rs, ZAF-1913). With a [redis] section the Worker psubscribes lumio:botmod:msg:* and runs each AsyncTriggerMessage through MessageRouter::handle_chat_trigger:
- Only
keywordandpatterntriggers are accepted; the install must belong to the account in the channel name (a message naming another account's install is refused like an unknown install). - A handler that is also a command keeps its
min_role: the router re-checks it againstcommand_min_roleson this path as on/v1/execute, so a keyword message cannot run a command handler below its role. Keyword and pattern handlers themselves have no minimum. - Every Worker replica receives every publish, so a message is claimed first (
SET NXonlumio:botmod:msg_claim:{install}:{type}:{handler}:{message_id}, 120 s); only the claiming replica runs it. A message without a platform message id is skipped. - At most
chat_triggers.max_in_flight(default 32) handlers run at once; a message arriving while all slots are busy is dropped with a warning. Each run has the router's 10 s deadline. - A non-empty
replygoes back to the chat it came from onlumio:bot:action:{platform}:{account_id}:chat_messagefor Twitch, Kick and YouTube;post_messagewith the originating text channel for Discord (the Discord bot checks the channel belongs to the account's guild). Handleractionsare posted to the API after the reply (see Handler actions).
Configuration: Installation → Bot Module Worker chat triggers.
Event-trigger listener (crates/lo-bot-module-worker/src/event_triggers.rs, ZAF-2398). With a [redis] section and [event_triggers] enabled (default true) the Worker psubscribes lumio:events:*, the channel the API publishes every stream event on (the same one the WebSocket server forwards to widgets):
- The account is taken from the channel name, never from the payload; a payload whose
account_idnames another account is dropped. Only payloads with anidand atypeare events - other shapes on the channel are ignored. - Matching is an in-memory lookup of the installs whose
events(manifesttriggers.events) contain the event type; an event no install subscribes to costs no Redis round-trip. Disabled and killed installs are skipped. - Each
(install, event)pair is claimed withSET NXonlumio:botmod:evt_claim:{install_id}:{event_id}(120 s), so one replica runs it. - At most
[event_triggers] max_in_flight(default 32) handlers run at once; a delivery beyond that is dropped with a warning. - The
event()handler receives the event payload unchanged. A reply goes tolumio:bot:action:{platform}:{account_id}for the event's platform (Twitch, Kick, YouTube); Discord events carry no channel, so their replies are dropped. Actions follow the reply.
Timer scheduler (crates/lo-bot-module-worker/src/timer.rs, timer_scheduler.rs, ZAF-2398). With [redis], [sync] and [timers] enabled (default true) the Worker runs every timer of the handler feed's timers list:
- Every replica schedules every timer. A timer fires at the slot boundaries of a deterministic per-
(install, timer)phase (timer_phase, SHA-256 of install id + timer name, modulo the interval), so all replicas aim at the same slot and firings spread across the interval. - Each firing claims
lumio:botmod:timer_claim:{install_id}:{timer}:{slot}withSET NX(TTL = interval), so exactly one replica runs it; there is no leader to fail over. Timer names that are not[A-Za-z0-9_-]{1,64}are hashed in the key. - At most once per slot; a missed slot is skipped, not caught up. At most
[timers] max_in_flight(default 32) timer handlers run at once; a firing beyond that is skipped. - The engine is reconciled on every registry change (snapshot poll, trigger-sync refresh, kill switch): new timers start, removed, disabled and killed installs stop, and a changed interval restarts the timer. A bundle update needs no restart - each firing reads the current bundle.
- A reply goes to every chat platform of the install except Discord.
onlyWhenLive. An install whoseinstall_confighasonlyWhenLive: true(the Timed Messages system module) runs only while at least one of its chat platforms is live per the API's channel-status cachelumio:channel_status:{account_id}:{platform}(is_online: true), and replies only on the live platforms. The cache is read only for such installs, once per firing.
Live registry updates (crates/lo-bot-module-worker/src/registry_sync.rs). Because events and timers bypass the bots, the Worker follows the same channels the bots do: lumio:botmod:trigger_sync:{account_id} refetches that account from the handler feed (?account_id=) and replaces its installs; lumio:botmod:kill:{account_id} sets or clears the kill flag on the account's installs at once. The Worker enforces enabled and the kill switch itself on every path. A lost publish is covered by the full snapshot poll.
Viewer input (chat commands and channel point rewards)
Viewers reach an extension only through chat commands and channel point rewards (Twitch and Kick). Lumio has no viewer page and no viewer identity; the manifest targets are editor and layer, and the manifest validator rejects interactive.
- Chat command (
!vote 2): the platform bot matches the command trigger, enforcesmin_roleand the cooldowns, and calls the Worker's/v1/execute, which runs the extension'scommand("vote", async (ctx, args) => …)handler (args = ["2"],ctx.user= the sender). - Channel point reward: the API publishes the redemption as
twitch:reward/kick:rewardonlumio:events:{account_id}; the event-trigger listener runs theevent("twitch:reward", …)/event("kick:reward", …)handlers of every install that lists the type in manifesttriggers.events. The account comes from the channel, never the payload; each(install, event)runs once (SET NXclaim); per install at most 50 event and timer runs per minute (RateLimitCategory::EventHandler); at most 32 event handlers in flight per Worker. The handler gets the stored event row: fortwitch:reward,rawis the EventSub redemption (user_name,user_input,reward { id, title, cost, prompt }); forkick:reward,rawcarriesuser_id,user_login,user_name,status,user_inputandreward { id, title, cost, prompt }. - Result to the surfaces: both handlers write
ctx.db(await ctx.db.insert("votes", { option })) and callctx.realtime.push("poll:votes", { option })(topic^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$, payload ≤ 16 KiB, ≤ 32 pushes per run, ≤ 300 pushes and ≤ 1 MiB of published messages per install and minute; a handler that throws publishes nothing). After a successful run the Worker publishes the pushes, the API relays them to the WebSocket channelextension_install:{install_id}, and the editor panel and the layer overlay receive them with the SDK hookuseRealtime("poll:votes", cb).
crates/lo-bot-module-worker/src/viewer_input_tests.rs covers the chat vote and both reward events end to end. Developer-facing guide: Viewer Input.
Handler actions
A handler result's actions ([{ action, params }]) are never executed by the Worker or the bots. They are posted to the API's internal endpoint POST /v1/internal/bot-modules/{install_id}/actions (crates/lo-bot-module-worker/src/actions.rs, System key with bots:internal, base URL and key from [sync] api_base_url / system_key; without them actions are dropped with a warning):
- After the reply, on a spawned task, so the handler budget is unchanged. The bots post command and moderate actions; the Worker posts keyword, pattern, event and timer actions.
- At most once - no retry on a timeout or
5xx, because a retriedchat:banis not idempotent at the platform. - The API (
apps/api/src/services/bot_module_actions.rs) takes the account from the install row, never from the request, and answers404unless the install is a live, enabled bot module of a non-suspended extension whose channel is not in its dissolution grace period and whose account kill switch is off. - Each action runs through the same dispatcher and manifest-permission gate as widget and app actions (
services::extension_actions): declared permission, plan feature (chat:*→feature:multichat,obs:*→feature:obs_remote,spotify:*→feature:music), params validation. A refusal before dispatch returnserror_code: "denied"with the widget path's message. - Rate cap:
[extensions] bot_module_action_rate_per_minute(default 120) actions per install and minute, counted inlumio:botmod:act_rl:{install_id}:{epoch_minute}(TTL 120 s). Over the cap →rate_limited; Redis unavailable →rate_limit_unavailable(fail-closed). - Install log: every outcome is written to
extension_install_logs(info/warn), at most one row per second and install per API process; after the cap is hit only the first refused action of the minute is logged. Rows carry the action name and, forchat:*,platform/user_id/message_id- never message text or other params. - Audit:
chat:ban,chat:unbanandchat:timeoutemitaccount:extension_moderationwithsource: "bot_module"(empty origin - a service-to-service call). A bot module'schat:deleteis not audited (extension_chat::is_audited); it is recorded in the install log only. - Delete lookback: after the platform deletes the message, a bot module's
chat:deleteflags it in Lumio's chat history and quotes its text in the moderation log only if it is at most 6 hours old (extension_chat::BOT_MODULE_DELETE_LOOKBACK_HOURS). The lookup and the soft-delete (lo_chat::get_message_text/soft_delete_platform_message) filter onplatformand thatcreated_atbound, so each per-message delete plans against the newest chat chunks only, never the whole retention window (volume pin P17). An older message is deleted on the platform and logged without text, but stays unflagged in the history. Widget, app and dashboard deletes have no lookback.
Wire format and response: REST API → internal bot-module actions.
Moderation (defineModerate)
The Twitch, Kick and YouTube bots run moderate handlers (crates/lo-bot-module-worker/src/moderate.rs, shared by the bots):
- For every chat message of an account with a
has_moderateinstall on that platform, the bot spawns onePOST /v1/execute(type: "moderate",function_name: "moderate") per install with a hard 500 ms client timeout. A timeout, error or saturation lets the message through (fail-open); chat is never blocked. - At most 16 moderate calls per account are in flight per bot process; a saturated account skips moderation for that message.
- Messages from the broadcaster and moderators are not moderated.
- The Discord bot does not call moderate handlers: no chat action supports Discord (the dispatcher's chat platforms are
twitch,kick,youtube), so every decision would be refused.
The decision maps to actions, which the bot posts with trigger_type: "moderate":
| Decision | Action |
|---|---|
allow | none |
delete | chat:delete { platform, message_id } |
timeout | chat:timeout { platform, user_id, duration_secs, reason? } (duration, default 600) |
ban | chat:ban { platform, user_id, reason? } |
warn | chat:send { platform, message } (message, else reason; nothing if both are empty) |
The module's manifest must declare the matching permission for a decision to run. An allow writes no install-log row; a decision or an error writes at most one row per second and install, without message text.
HTTP surface:
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/v1/execute | POST | Authorization: SystemKey <key> (constant-time compared) | Execute a command or moderate handler and return { reply, actions } |
/v1/server-functions/execute | POST | Authorization: SystemKey <key> (constant-time compared) | Run a handler server function (query() / mutation() / action()) for the API and return { data, realtime } (ZAF-2542, ZAF-2543) |
/health | GET | none | Health check |
/v1/server-functions/execute serves extensions of any category, not only bot modules: the API resolves and authorises the call, then sends the install, the definition's category, the caller's args, ctx.auth (userId, role), the install config and the published server.js of the version the install runs. The worker does not consult its InstallRegistry for it. It runs the export named functionName (it must be a query() / mutation() / action() definition of that category; no fallback to another export) as handler(ctx, args) with a 5 s / 256 MB isolate, ctx.* bound to the worker host and scoped to the install, deny-all egress and the per-install Query / Mutation / Action invocation limit. That limit fails closed for server functions: a configured Redis that does not answer refuses the call (429 RATE_LIMITED), while chat, event and timer handlers keep failing open. Every call also needs a slot of the server-function gate, [server_functions] max_in_flight (default 16) per replica and max_in_flight_per_account (default 4); a call that finds none is refused with 429 RATE_LIMITED at once, never queued, so outside callers cannot crowd out the chat, event and timer handlers on the same worker. A JavaScript error returns only its first line (no stack trace); any other failure (the isolate host, the result, the bundle wrapper) returns the fixed message Handler execution failed and is logged on the worker, because its text can name internal paths; timeout and memory limit are reported from the watchdog and heap-limit flags, never from the error text. Answers: 200 { data, realtime } (realtime = the handler's ctx.realtime.push calls, unpublished: the API validates and publishes them); 429 RATE_LIMITED, 504 HANDLER_TIMEOUT, 422 HANDLER_MEMORY_LIMIT / HANDLER_ERROR as { code, message }. The body limit of this route is 8 MiB. Server-function calls write no extension_install_logs / extension_errors rows.
The Worker refuses to start if worker.system_key is shorter than 16 characters -- an empty key would let an empty SystemKey header through.
Bot-side lifecycle:
- On connect, the bot fetches the account's trigger config from the API:
GET /v1/internal/bot-modules/{account_id}/triggers(System key carryingbots:internal). The response is{ installs: [...], killed: bool }.installslists only enabled installs of extensions that are not suspended;killedis the account's stored kill switch (account_extension_kill_switch, defaultfalse). compile_triggers()pre-compiles the regex patterns intoAccountExtensionTriggers, keyed by account in an in-memory store.- Every chat message is matched against that store in-process (no network hop).
- A command match becomes a
SyncExecuteRequestsent to the Worker'sPOST /v1/execute(URL and deadline from the bot's[worker]config --url,timeout_ms = 500). The returnedreplyis sanitised and sent to chat, then the returnedactionsare posted to the API (trigger_type: "command"); the match is exclusive (the bot stops after it). - A keyword or pattern match becomes an
AsyncTriggerMessagepublished tolumio:botmod:msg:{platform}:{account_id}, where the Worker's chat-trigger listener runs it. These matches are non-exclusive -- the bot keeps evaluating the remaining installs.
All four bots follow these steps. Command triggers pass the shared lo_bot_commands::access::authorize gate (min_role, then cooldowns) before the request is sent; a denied command is consumed silently. On Discord the prefix-message handler runs extension_triggers::plan_dispatch after dynamic commands; the sender's role comes from the guild (owner = broadcaster, a role with Manage Messages = moderator, everyone else viewer).
Trigger refresh: the bots psubscribe to lumio:botmod:trigger_sync:* and lumio:botmod:kill:*. A message on the trigger-sync channel makes the bot re-fetch and recompile that account's trigger set; the kill channel sets or clears the account's extension kill switch. Both only update in-memory state, so every running instance applies them before the leader filter (leader.is_leader()), which gates only the side-effecting lumio:bot:action:* messages.
Kill switch on every reload path: every path that loads an account's triggers - startup, a channel joined after startup (the Twitch/Kick channel poll, a YouTube channel's first chat session, a Discord GuildCreate) and a trigger sync - applies the feed's killed flag together with the triggers through the shared lo_bot_module_worker::kill_switch::apply_trigger_config. The flag is applied first and in both directions (true kills, false re-enables), so a reload never brings an account's triggers back while its switch is set.
The API publishes lumio:botmod:trigger_sync:{account_id} from apps/api/src/services/bot_module_trigger_sync.rs whenever the trigger feed's answer changes, on both protocols and for bot_module extensions only:
| Change | GraphQL | REST | Accounts reloaded |
|---|---|---|---|
| Install | installExtension | POST /v1/extension-installs | the installing account |
| Uninstall | uninstallExtension | DELETE /v1/extension-installs/{id} | the uninstalling account |
Per-install config (incl. config.triggers overrides) | configureExtension | PATCH /v1/extension-installs/{id} | the configuring account |
| Admin suspension | suspendExtension | POST /v1/admin/extensions/{id}/suspend | every account with the module installed |
| Default system extensions installed for a new account | createAccount | POST /v1/accounts (and Stripe checkout account creation) | the new account |
The publish is best-effort: a failure is logged and never fails the mutation, and a restarted bot reads the feed fresh. Turning extensions off for an account is the kill switch (lumio:botmod:kill:*), not a trigger sync. pin_version and set_auto_update change neither feed (both read extension_installs.version_id) and publish nothing. The Bot Module Worker follows the same channel to refresh its install registry.
API-side trigger resolution (apps/api/src/routes/bot_module_extensions.rs): the feed joins extension_installs → extensions → extension_versions for every category = 'bot_module' install and, per install, resolves the effective trigger set by layering optional per-install overrides from extension_installs.config.triggers over the version manifest's triggers (COALESCE: config wins field-by-field, manifest is the default). Commands, keyword substrings and regex patterns come from the manifest; has_moderate is set when the manifest declares triggers.moderation = true (the system moderation modules). The body is returned raw, without an ApiResponse envelope, because the bots deserialize it directly into TriggerConfigResponse. killed is read from account_extension_kill_switch, so a bot that restarts after the switch was set starts killed; the pub/sub channel carries live changes on top.
Each trigger carries a handler string, which is the function_name the bot sends to /v1/execute; the Worker looks it up in InstallRegistry.handler_code. The naming contract (the registry population must key handler_code identically) is: command → command name, keyword → keyword text, pattern → regex source, and a moderation handler is invoked with type: "moderate".
Registry population (handler-bundle feed). The Worker's InstallRegistry is filled from a separate internal feed: GET /v1/internal/bot-modules/handlers (System key carrying bots:internal), resolved in apps/api/src/routes/internal_extension_handlers.rs. Where the trigger feed above delivers only trigger metadata, this feed delivers, per install, the executable handler bundle (the compiled server.js downloaded from storage) plus trusted context - account_id, extension_id, the installed version, install_config, enabled, platforms, has_moderate, the list of valid handlers, events (manifest triggers.events), timers ([{ name, interval_secs }]) and killed (the account's kill switch). timers covers the SDK form triggers.timers[{ name, interval }] (entries under 60 s or without a name are skipped) and the legacy triggers.timer: true form - one timer named "timer" whose interval is the install's intervalSeconds setting, clamped to 60..86400, default 300. The Automation Worker has the analogous GET /v1/internal/automation-nodes/handlers (which additionally carries node_type). Both accept optional account_id / install_id query params so the worker sync-loop can do a full initial load and then incremental refresh on install / uninstall / update. handler_code is null when the bundle cannot be resolved (missing file or storage unavailable); the install is still reported so the worker keeps full context. The Bot Module Worker requests the bot-module feed with bundles=by_version: each install then carries its version_id and handler_code: null, and every distinct bundle travels once in a top-level bundles map keyed by version_id. A version's bundle never changes, so N installs of one version cost one bundle in the body and one storage download.
Worker sync-loop. The worker actually pulls this feed at runtime (crates/lo-{bot-module,automation}-worker/src/sync.rs): on startup the SyncClient GETs the full cross-account snapshot and calls InstallRegistry::load_snapshot, then re-syncs the full snapshot every poll_interval_secs. The Bot Module Worker's re-sync is conditional: it sends the previous snapshot's ETag as If-None-Match, and the API answers 304 Not Modified while no install's metadata has changed. It compares before it downloads any bundle, so an unchanged poll touches neither storage nor the registry. The API withholds the ETag while a bundle is unresolvable, so the next poll retries the download. A failed fetch is logged and the last-good registry is kept (fail-open), so a brief API outage never stops the worker. The loop runs only when the [sync] config section is fully set - enabled (default true), api_base_url, system_key (the Bearer key presented to the feed), and poll_interval_secs (Bot Module Worker default 300, Automation Worker 30). For the Bot Module Worker the poll is a safety net: with a [redis] section it applies lumio:botmod:trigger_sync:* and the kill switch live. Without [redis] the poll is the only refresh path and runs at most every 30 s. An unconfigured worker starts with an empty registry (every request short-circuits) rather than crashing. The bot-module conversion maps every advertised handlers name to the single shared server.js bundle (InstalledBotModule.handler_code: HashMap<name, bundle>); disabled installs and installs with an unresolvable bundle are kept so requests short-circuit cleanly.
Redis Channels
Channel names are built by crates/lo-cache/src/pubsub.rs:
| Channel | Direction | Purpose |
|---|---|---|
lumio:botmod:msg:{platform}:{account_id} | bots → Worker | Keyword/pattern trigger matches, run by the Worker's chat-trigger listener |
lumio:botmod:msg_claim:{install_id}:{type}:{handler}:{message_id} | Worker (key) | Claims one message for one replica (SET NX, 120 s) |
lumio:bot:action:{platform}:{account_id} | Worker → bots | Keyword/pattern/event/timer handler replies (chat_message; post_message on Discord for keyword/pattern) |
lumio:events:{account_id} | API → Worker (and WebSocket) | Stream events, run by the Worker's event-trigger listener |
lumio:botmod:evt_claim:{install_id}:{event_id} | Worker (key) | Claims one event for one install and replica (SET NX, 120 s) |
lumio:botmod:timer_claim:{install_id}:{timer}:{slot} | Worker (key) | Claims one timer slot for one replica (SET NX, TTL = interval) |
lumio:channel_status:{account_id}:{platform} | API (key), read by Worker | Live status for onlyWhenLive timers |
lumio:botmod:act_rl:{install_id}:{epoch_minute} | API (key) | Per-install action rate counter (TTL 120 s) |
lumio:botmod:trigger_sync:{account_id} | API → bots, Worker | Trigger set / install registry invalidation (install, uninstall, per-install config, admin suspension, new account) |
lumio:botmod:kill:{account_id} | API → bots, Worker | Extension kill switch |
lumio:botmod:reload:{account_id} | API → | Bot module reload |
V8 Execution
Extension bot module handlers execute inside the same V8 isolate sandbox used by all extension server functions (crates/lo-extensions-v8/). The handler context (ctx) includes:
The ctx object is assembled in the wrapper (build_module_wrapper in crates/lo-extensions-v8/src/module_executor.rs). auth, config and user are inlined as plain JavaScript objects. db, cache, secrets, fetch and defer come from globalThis.__lumio_host, which JS_HOST_PREAMBLE (crates/lo-extensions-v8/src/ops/bridge.rs) installs before the sandbox hardening removes Deno. The preamble captures the single op op_ext_call(kind, payload); the op validates the call, counts it against the per-run caps (OpLimits: 25 db / 50 cache / 10 secret / 10 fetch / 5 defer) and runs the embedder's ExtensionHost future on the embedder's Tokio runtime, bounded by the phase deadline. Errors come back as a JSON envelope that the preamble rethrows as Error. Without a host binding (server-function and automation paths today) the members fall back to inert stubs.
| Property | Description |
|---|---|
ctx.auth | Caller identity (userId, accountId) |
ctx.config | Validated install config from extension_installs.config |
ctx.user | Chat user who triggered the handler (id, name, displayName, platform, role, isMod, isVip, isSub) -- null outside chat contexts. name is the login / @handle and displayName is the display name; on Twitch the two differ for localized names (login pocom_2026 vs display こさむん). Built-in template commands expose the same split as {user_login} / {user}, and the command→automation trigger publishes both as user.login / user.name. |
ctx.cache | One Redis hash per install, lumio:ext:cache:{install_id} (lo_cache::ext_cache): ≤ 256 fields × 4 KiB, field TTL via HEXPIRE (default 1 h, max 24 h), Lua scripts for set / increment. Writes fail closed while Redis used_memory > 75 % of maxmemory (sampled every 10 s). UNLINK on uninstall (REST + GraphQL), and right after the commit for every install removed by an account delete (channel dissolution, admin delete) or a draft extension delete. Best-effort: a Redis failure is logged and the field TTLs bound the data |
ctx.fetch | ops::fetch::execute_fetch with the manifest egress allow-list from the handler feed: HTTPS, global-unicast only (DNS pinned), no redirects, 5 s per request, 1 MiB (sync) / 10 MiB (async) body |
ctx.defer() | Up to 5 jobs, run in the same isolate after the handler result went back to the router, under a shared 10 s budget (DEFER_BUDGET); only after a successful handler; not persisted |
ctx.db | lo_extensions_runtime::data::execute on the extensions database ([extensions.database], a DML login, 5 connections per replica): the install's account scope, enforced by row-level security as well as by the statement, typed binds, 200 ms (sync) / 2 s (async) statement timeout, per-install Query / StorageWrite limits shared with the API server functions (writes fail closed without Redis). Tables come from the version's data_schema, provisioned at approval. Without a URL it throws ctx.db is unavailable |
ctx.realtime | push(topic, payload) collects messages in the isolate (topic 1-64 chars of [A-Za-z0-9_.:-], payload ≤ 16 KiB, ≤ 32 per run; a bad push throws). After a successful run the executor hands them to WorkerHost::publish_realtime, which publishes them off the reply path on lumio:extension_install:{install_id} under the shared per-install caps of 300 pushes and 1 MiB of serialized { topic, payload } messages per minute (lo_extensions_runtime::realtime, Redis keys ext-fn:{install_id}:realtime and ext-fn:{install_id}:realtime-bytes). One Lua call admits the longest prefix of the batch both caps still hold and raises both counters by exactly that (INCRBY), and the admitted pushes go out in one pipelined round trip. The byte cap protects the API's event relay, which reads every install's channel over one pub/sub connection that Redis would drop on an output-buffer overflow (client-output-buffer-limit pubsub); the API relays them to WebSocket extension_install:{install_id}. A failed run publishes nothing; without Redis the pushes are dropped |
ctx.secrets | get(key) → the extension's owner-scope secret (extension_secrets row with account_id IS NULL) or null. The extension comes from the install registry, never from handler input; the installing account plays no part, so every install of an extension reads the same value. See Secrets path below |
Secrets path. The worker never holds the API's auth.token_encryption_key, the master key for every OAuth token, because it runs untrusted handler code. Instead GET /v1/internal/bot-modules/extensions/{extension_id}/secrets (system key with bots:internal, Cache-Control: no-store) decrypts each owner-scope value and re-wraps it under an independent key shared only by the API ([extensions] bot_module_secrets_key) and the worker ([sync] secrets_key): AES-256-GCM, format w1.{nonce}.{ct}, extension id + key name bound as associated data, so a wrapped value never opens under another extension or key name, and an at-rest v1/v2 ciphertext never opens as a wrapped one. lo_bot_module_worker::secrets::SecretsStore caches the wrapped map per extension for 60 s and unwraps one value per ctx.secrets.get; plaintext is never cached or logged. A fill is single-flight per extension: concurrent calls of one extension share one feed request, and a slow fill for one extension never delays another extension's reads. A call that has to wait for a fill gives up after 1 s (SECRETS_FETCH_TIMEOUT, the call is on the per-message path) and throws ctx.secrets is unavailable. Consequences: a changed or deleted secret reaches handlers within 60 s; a failed feed call or a key mismatch throws ctx.secrets is unavailable (nothing stale is served past the TTL); an empty secrets_key (or no [sync] api_base_url / system_key) turns ctx.secrets off with the same error. To rotate the wrap key, restart the API and every worker with the new value together; nothing is re-encrypted at rest. Crypto lives in lo_extensions_v8::ops::secrets (wrap_secret / unwrap_secret).
WorkerHost (crates/lo-bot-module-worker/src/host.rs) implements ExtensionHost and enforces the per-install per-minute RateLimitCategory limits in Redis (ext-fn:{install_id}:{suffix}): every command (60), keyword/pattern (100) and event (50) run, every cache op (200) and every deferred job (10). Moderate and timer runs are not limited. A Redis error admits a handler run (its per-run caps still bound it) but fails a cache op or deferred job closed. Without [redis] the worker runs an offline host: ctx.cache and ctx.defer() fail closed, ctx.fetch works.
The isolate defaults are 256 MB heap and a 10 s timeout (IsolateConfig::default() in crates/lo-extensions-v8/src/isolate.rs), with no filesystem access and no dynamic code evaluation. Bot module handlers run tighter: the executor is called with a 500 ms timeout for sync command/moderate handlers, keeping the bot inside its own HTTP deadline.
Bundle evaluation (module_executor). The registry holds the real compiled server.js - an esbuild ESM module with export default / named exports and external @zaflun/lumio-sdk imports, not a bare async (ctx, args) => … expression. crates/lo-extensions-v8/src/module_executor.rs (execute_bundle_handler, or execute_bundle_handler_with_host with a host binding) evaluates it: it rewrites esbuild's regular output (hoisted imports + one trailing export{…}) onto the classic-script isolate path, binding the stripped SDK imports to an in-isolate shim of the pure command / keyword / defineModerate / defineAutomationAction / … tagging helpers, then selects the export the request targets (by __trigger for the low-level tagged handlers, by __type for the define* definitions, falling back to default) and invokes it with the calling convention its category expects. The shim adds no host authority - the isolate boundary is unchanged. The routers (MessageRouter, NodeRouter) call this via execute_bot_module_bundle / execute_automation_node_bundle; the older bare-expression execute_*_handler path is retained only for inline unit fixtures.
Trigger Matching Flow
Matching happens in the bot (apps/{platform}-bot/src/extension_triggers.rs), not in the Worker:
1. Chat message arrives on the bot's platform connection
2. Bot iterates the account's installs, skipping installs whose
`platforms` list excludes the current platform
3. match_triggers() checks, in this fixed order, and returns the FIRST hit:
a. command → message starts with the bot prefix and the first word
equals a command name or alias (case-insensitive)
b. keyword → lowercased message contains the keyword
c. pattern → compiled regex finds a match
4. Command → access gate: sender role ≥ min_role, then the cooldown slots
(lo_bot_commands::access, same as dynamic commands); denied →
ignored silently, bot stops
→ sync POST /v1/execute; reply sent through the platform
transport wrapper (sanitised there); bot stops
Keyword → Redis publish to the Worker; bot continues to the next install
Pattern → Redis publish to the Worker; bot continues to the next install
On Discord, steps 2-4 are extension_triggers::plan_dispatch, which returns the
sync requests and Redis publishes for handlers/events.rs to send.
Disabled triggers are skipped, and a pattern whose regex fails to compile is dropped at compile time with a warning rather than failing the install.
Enforcement points
| Check | Where | On failure |
|---|---|---|
min_role + cooldowns of a command trigger | bot, extension_triggers::authorize_command → lo_bot_commands::access::authorize (the gate dynamic commands use) | ignored silently; cooldown is armed only when the gate passes |
min_role again | worker, MessageRouter::handle_sync_request (command_min_roles from the handler feed, resolved like the trigger feed) | empty response; a handler with no known min_role is refused |
min_role of a command handler named by a keyword/pattern message | worker, MessageRouter::handle_async_trigger (same command_min_roles lookup) | nothing runs, nothing is sent |
| Keyword/pattern message bound to its channel | worker, MessageRouter::handle_chat_trigger: type is keyword/pattern, install belongs to the channel's account | refused, nothing is sent |
| Outbound text (reply, automation message, command response) | the platform transport wrapper: lo-twitch-irc send/reply, kick/youtube send_*_message, discord-bot send.rs → lo_chat_outbound::sanitize | control chars and line breaks become spaces (Discord keeps \n), leading / / . removed, capped at the platform maximum |
| Shared-connection throughput (Twitch) | TwitchIrcClient token bucket | a send that would wait more than 2 s is dropped with RateLimited |
The sender role is the bot's mapping of platform badges (TriggerUser.role); an
unknown role string counts as everyone, an unknown min_role as broadcaster.
Key Files
| Path | Description |
|---|---|
apps/bot-module-worker/src/main.rs | Worker binary: config load, system-key guard, HTTP server |
crates/lo-bot-module-worker/src/sync_handler.rs | POST /v1/execute + GET /health handlers, constant-time key check |
crates/lo-bot-module-worker/src/router.rs | MessageRouter -- sync and async dispatch into the executor |
crates/lo-bot-module-worker/src/chat_triggers.rs | Chat-trigger listener: lumio:botmod:msg:* → router → reply on lumio:bot:action:* |
crates/lo-bot-module-worker/src/executor.rs | execute_bot_module_handler -- V8 execution wrapper |
crates/lo-bot-module-worker/src/types.rs | Wire types (SyncExecuteRequest, AsyncTriggerMessage, trigger configs) |
crates/lo-bot-module-worker/src/config.rs | InstallRegistry -- in-memory installs and handler code |
crates/lo-bot-module-worker/src/sanitizer.rs | Redacts UUIDs, mentions, tokens and IPv4 from developer-visible output |
crates/lo-bot-module-worker/src/event_triggers.rs | Event-trigger listener: lumio:events:* → event() handlers |
crates/lo-bot-module-worker/src/timer.rs | TimerEngine -- per-install interval timers, deterministic phase and slot |
crates/lo-bot-module-worker/src/timer_scheduler.rs | Reconciles timers with the registry, slot claims, onlyWhenLive |
crates/lo-bot-module-worker/src/registry_sync.rs | Live registry updates from trigger_sync and kill channels |
crates/lo-bot-module-worker/src/actions.rs | Posts handler-result actions to the API (shared by Worker and bots) |
crates/lo-bot-module-worker/src/moderate.rs | Moderate call (500 ms, fail-open, per-account cap) and decision → action mapping, used by the bots |
apps/api/src/services/bot_module_actions.rs | Internal actions endpoint logic: runnable-install check, rate cap, install log |
crates/lo-extensions-v8/src/executor.rs | Handler wrapper, ctx construction, HandlerCategory |
crates/lo-extensions-v8/src/isolate.rs | Isolate limits and script execution |
apps/{platform}-bot/src/extension_triggers.rs | Trigger compilation, matching and the min_role/cooldown gate on the bot side |
crates/lo-bot-commands/src/access.rs | Shared access gate (min_role, cooldown slots) for dynamic commands and extension command triggers |
crates/lo-chat-outbound/ | Outbound sanitiser, token bucket and keyed cooldown shared by all bots |
apps/api/src/routes/internal_extension_handlers.rs | Handler feed for the worker (incl. command_min_roles, events, timers, killed) and the internal actions endpoint |
apps/api/src/routes/bot_module_extensions.rs | GET /v1/internal/bot-modules/{account_id}/triggers |
apps/api/src/services/bot_module_trigger_sync.rs | Publishes lumio:botmod:trigger_sync:{account_id} on install, uninstall, configure, suspension and new-account default installs |
apps/api/src/routes/bot_modules.rs | Built-in module config REST (bot-modules:read / bot-modules:edit) |
Security Headers
The Extension Supervisor and Runtime apps serve HTTP security headers via _headers files (Cloudflare Pages, and Workers Static Assets for the per-extension Runtime hosts). Each app generates its dist/_headers at build time from the same origin lists it compiles into its bundle, so the CSP and the runtime postMessage allowlist always describe the same origins:
- Supervisor (
apps/extension-supervisor/tsup.config.ts):frame-srcrestricts which origins can be iframed (the Runtime: each runtime origin template as a host wildcard such ashttps://*.runtime.ext.lumio.vision, plus the legacy shared Runtime origins during the rollout).frame-ancestorsrestricts who can embed the Supervisor (the webapp). - Runtime (
apps/extension-runtime/tsup.config.ts):worker-src 'self'allows only the Worker served from the page's own origin.frame-ancestorsrestricts embedding to Supervisor origins only.connect-srcallows Sentry error reporting.
Because the lists are per-environment, a build only advertises the origins of the environment it is built for - the production build's frame-ancestors names only supervisor.ext.lumio.vision, the staging build only lumio.supervisor.staging.zaflun.dev.
Origins
Every extension runs in its own Runtime origin, <label>.<runtime host>, where the label is the extension UUID as 32 lowercase hex characters (@zaflun/lumio-protocol/origin). Production uses https://{label}.runtime.ext.lumio.vision, prod preview and staging https://{label}.lumio.ext.{prod,staging}.zaflun.dev, local dev http://{label}.localhost:3201. The Supervisor loads an extension only from the origin bound to its extensionId, so the browser keeps the client-side state of two extensions apart. The full table, the legacy shared origins and the hosting setup are in Extension Supervisor.
When adding a new environment domain, update the origin and template defaults in both tsup.config.ts files (and the matching ALLOWED_*_ORIGINS / EXTENSION_RUNTIME_ORIGIN_TEMPLATES / LEGACY_RUNTIME_ORIGINS in .github/workflows/deploy-extension-apps.yml) alongside DNS, the CF Pages configuration and the route in apps/extension-runtime/wrangler.jsonc. There is no committed _headers file to edit.
Config Validation
Extension config values are validated server-side against the extension's config_schema before writing to extension_installs.config. The validator is validate_config_values() in crates/lo-extensions/src/config_validator.rs.
Field types accepted in a config_schema (VALID_CONFIG_TYPES): string, number, boolean, color, select, textarea, slider, font, icon, image, sound, multiselect, group, divider, info, channel, designer, presets. A field may also carry min / max / step, options, placeholder, rows, unit, collapsed, nested fields (for group), and a visible_when condition.
The validator produces a Vec<ConfigValidationError> covering:
| Error | Condition |
|---|---|
UnknownKey | A submitted key is not in the schema |
RequiredFieldMissing | A required field is absent or null |
TypeMismatch | Value type does not match the declared field type |
InvalidSelectValue | select value not in the declared options |
InvalidMultiselectValue | A multiselect entry not in the declared options |
InvalidColorFormat | color value is not #RRGGBB hex |
OutOfRange | slider value outside [min, max] |
group, divider and info fields are presentational and carry no value check.
Both GraphQL (configureExtension mutation) and REST (PATCH /v1/extension-installs/\{id\}) call the same validator, so the two protocols reject identically. The stored config is passed to V8 handlers via ctx.config.