Bridge tools¶
The assistant carries tools that wrap Limnoria's stock plugin commands. When a user asks in natural language ("what time is it in Tokyo?", "have you seen alice?"), the model calls the corresponding Limnoria command directly instead of relying on a custom implementation.
This page describes the bridge's gating rules and the knobs operators use to control what the model sees.
How it works¶
The bridge injects two tools: run_limnoria_command and
search_bridge_commands. enumerate_commands in
plugins/llm/src/llm/limnoria_bridge.py walks irc.callbacks each time
the pair is built, so the tool surface tracks whatever Limnoria has
loaded at that moment. Newly loaded plugins become visible without a bot
restart.
run_limnoria_command's description carries the whole exposed command
list, one line per command with its argument syntax and docstring. When
the mutation gate is closed and an allowlisted plugin has writes to hide,
the list ends with (write commands hidden — set bridgeAllowMutating True
to expose). search_bridge_commands substring-searches that same list
(limit 1 to 25, default 10); it exists because Limnoria's
Misc.apropos matches command names only, not descriptions.
Both tools ride @ask, nick-addressed mentions, PMs, and @rp/verse
turns — everything that reaches _ask_impl, which builds the pair
unconditionally. @code, @draw, reminder fires, and scheduled tasks
never carry them.
The bridge exposes a command when all of these hold:
bridgeEnabledisTruefor the channel (defaultFalse).- The owning plugin is in the operator allowlist
(
bridgeAllowedPlugins), or in the curatedDEFAULT_ALLOWED_PLUGINSset when the allowlist is empty. - The command is not in
DENY_COMMANDS. Examples:misc more(interactive pagination) and thewebfetch-style commands, which the bridge denies as server-side request forgery risks. - Either the command is read-only, or
bridgeAllowMutatingisTruefor the channel. - The caller holds the Limnoria capability for the command, the same check Limnoria runs for IRC dispatch.
Default allowlist¶
The curated set applies when bridgeAllowedPlugins is empty. Every
plugin in the set is either read-only by nature or has its writes gated
behind bridgeAllowMutating.
Misc, Time, Math, Utilities, Seen, Web, Later, Note,
Karma, QuoteGrabs, RSS, DDG, Network
Network is in the set for whois, whowas, latency and uptime —
the "who is alice?" and "are you lagging?" questions. Its connection
management (connect, disconnect, reconnect, authenticate) and
its re-dispatch commands (command, cmdall) are in DENY_COMMANDS,
so the model never sees them whatever the caller's capabilities.
For the canonical, current list see DEFAULT_ALLOWED_PLUGINS in
plugins/llm/src/llm/limnoria_bridge.py.
Loading the plugins¶
bridgeAllowedPlugins only matters for plugins Limnoria has loaded. Out
of the curated set, only Misc loads on its own: alwaysLoadImportant
covers Admin, Channel, Config, Misc, Owner and User, and the bridge
hard-denies every one of those but Misc. Everything else, Utilities
included, needs an explicit load as bot owner:
@load Utilities
@load Time
@load Math
@load Seen
@load Web
@load Later
@load Note
@load Karma
@load QuoteGrabs
@load RSS
@load DDG
@load Network
Limnoria persists each load to bot.conf automatically. The bridge
picks them up on the next assistant turn, with no bot restart needed.
To see the commands a plugin exports, ask the bot:
Whatever shows up under @list <Plugin> is what the model can call,
minus anything held back by DENY_COMMANDS or MUTATING_COMMANDS.
Mutating commands¶
When bridgeAllowMutating is False, the default, the bridge filters
out commands that change persistent state, send messages on behalf of
another user, or read with side effects. Filtering happens at
enumeration and at dispatch, as a defence-in-depth measure. The full
set lives
at the top of limnoria_bridge.py under MUTATING_COMMANDS.
To allow mutations on a specific channel:
Operator caveats:
RSSmutations can announce feed entries to every channel subscribed to the feed. Enabling mutations in one channel can cause writes elsewhere.Karmaclear,dump, andloadare destructive against the karma database.Factoidswhatiscounts as mutating because the defaultkeepRankInfowrites a usage counter on every read.
Refused calls¶
A refusal is not silent. Dispatch hands the model a JSON envelope, so it
can correct itself or tell the user why. A call that runs returns
{"status": "ok", "reply": "<captured text>"}; a refusal returns one of:
{"error": "denied: write commands disabled"}— the command is inMUTATING_COMMANDSandbridgeAllowMutatingisFalse.{"error": "not permitted: Plugin.command"}— the caller lacks the Limnoria capability for that command.{"error": "denied: Plugin.command"}— aDENY_COMMANDSentry, or a plugin inDENY_PLUGINS.{"error": "unknown plugin: X"}or{"error": "unknown command: Plugin.command"}— the model invented a name.{"error": "command failed"}— the plugin raised. The exception text goes to the log, not to the model; anything the plugin printed before raising comes back underpartial_output.{"error": "<tokeniser message>"}— the argument string failed Limnoria's tokeniser: an unbalanced quote, bracket, or pipe. The message passes through verbatim so the model can correct its own call.
Live IRC lookups¶
irc_lookup is a third per-request tool that rides with the bridge
pair whenever ircLookupEnabled is on for the channel (the default),
bridge or no bridge. It exists because nothing stock answers the first
two questions — Limnoria has no LIST command at all, and
Channel.nicks only reads channels the bot has joined — and because a
whois buried in the bridge's command table is one the model tends to
skip; as a named kind on its own tool it gets picked.
kind |
What it sends | What comes back |
|---|---|---|
channels |
LIST |
Up to 25 public channels sorted by user count, each with name, users and a cleaned topic, plus total. target filters by exact name or glob (#linux*). |
names |
NAMES <target> |
count and up to 100 nicks with their @/+ prefixes, for any channel the server will show — joined or not. |
whois |
WHOIS <target> <target> |
user, host, realname, server, channels (with prefixes), account, oper, away, idle_seconds, signon. The doubled nick asks the user's own server, so idle time comes back. |
whowas |
WHOWAS <target> 3 |
Up to 3 entries for a nick that has left, each with user, host, realname, and where the server says so server, last_seen and away. |
who |
WHO <target> (WHOX) |
count and up to 100 members, each with away, oper, status (@/%/+), bot, account (null when not logged in), host and realname. |
topic |
TOPIC <target>, or LIST when the bot is not in the channel |
topic, plus set_by and set_at for a joined channel. For a channel the bot is not in, the setter is unknown and only public channels answer. |
network |
LUSERS, VERSION, ADMIN |
users (user, oper and channel counts), server (software and server name) and admin (contact lines). |
ctcp_version / ctcp_ping |
CTCP VERSION / PING to <target> |
client, or lag_ms. Many clients ignore CTCP; then reply is null with a note. |
history |
CHATHISTORY LATEST <this channel> * <count> |
lines from the server's history, newest last, as HH:MM <nick> text in UTC. count is 1–100, default 50. Only the channel the request came from; refused in a PM. |
history is the "what did I miss" kind. It ships ordinary channel
chatter to the provider, so it obeys privacyOptInOnly. With that
setting on (the default) the schema leaves out the kind, its count
parameter and every mention of history, and the handler refuses a call
that names it anyway without sending anything. It also needs the server to grant draft/chathistory, which
the bot requests at connect.
The server enforces its own visibility rules: a +s channel is absent
from LIST and answers NAMES with nothing, exactly as it would for a
user typing /list or /names. The bot adds no privilege.
LIST is the one expensive request here, so the reply is cached for
60 seconds per network and concurrent askers share one in-flight
request. A server that never closes the reply (or answers RPL_TRYAGAIN)
surfaces as an error to the model after 15 seconds rather than a hung
turn. The same code backs the @channels and @names commands. Every
dispatch logs one irc_lookup: kind=… target=… line, so "did the model
call it?" is a grep.
Native tools¶
Beyond the bridge, the assistant carries its own tools. Users never call these directly; the model picks them from conversation. Which ones it is offered depends on the route that handled the message:
| Route | Entry points | Tools offered |
|---|---|---|
| chat | @ask, nick-addressed mentions, PMs |
check_service_status, fetch_url, generate_code, generate_image, save_memory, search_web; plus set_reminder and schedule_llm_task where pendingTasksEnabled is on. Six with the shipped defaults; eight when pendingTasksEnabled is on; one fewer in each case when both statusPageUrls and statusQueryablePages are empty. |
| verse | @rp, a live sticky @rp session, the one-shot ambient-prose promotion |
fetch_url, generate_code, generate_image, save_memory, search_web (5) |
| code | @code |
fetch_url, generate_code, search_web (3) |
| draw | @draw |
generate_image (1) |
| remind_action | reminder and scheduled-task fires | all 21 |
assistant.py defines 21 tool schemas, but defining one is not
advertising it. Thirteen are hidden from chat and verse because each
duplicates a command the user can already type:
list_memories,update_memory,delete_memory,clear_memories,cleanup_memories→@memories,@memories edit <id> <text>,@memories delete <id>,@memories clear,@memories cleanupset_instruction,clear_instruction→@instruct <instruction>,@instruct clearget_usage,get_channel_usage→@usage [<nick or #channel>]forget_context→@forget [<channel>]
list_pending_tasks, cancel_pending_task and cancel_all_pending_tasks
used to be on this list. They are back in chat: with set_reminder
visible and the cancel tools hidden, chat answered "cancel my reminder"
with a made-up confirmation and the reminder fired anyway.
Keeping the advertised surface small is a correctness measure, not
tidiness. xai/grok-4-1-fast-reasoning starts returning empty
completions once the tool count climbs past roughly 25 — four
empty-response incidents on 2026-05-10, more than any day in the
preceding 30 — and a non-reasoning model asked to pick one tool out of
twenty will sometimes pick none and answer from its own invention, which
is how a draw request came back with a fabricated image URL on
2026-08-01. Hiding
these costs no capability: the handlers still exist, the commands above
still work, and memories still arrive on their own through the
background extraction pass. save_memory stays on the chat surface
because it is the one write extraction cannot replace — an explicit
"remember this" should stick at once rather than wait for the
candidate-reinforcement threshold.
Reminder fires keep all 21. A scheduled task runs as its creator with no user present to type a command, so it may legitimately need to tidy up after itself.
set_reminder and schedule_llm_task appear on the chat surface only
when pendingTasksEnabled is on for the channel, and it defaults to
off. Their schemas and prompt rules cost roughly 1,100 prompt tokens on
every completion, so channels that never schedule anything should not
pay for them. The @remind command and already-scheduled fires ignore
the gate. The matching list and cancel tools are off the chat surface
either way: users list and cancel both kinds with @remind list,
@remind del and @remind clear.
check_service_status covers two tiers of page. statusPageUrls entries back
the poller's cached snapshot: one entry per page when the model omits
service, or a single named lookup. statusQueryablePages entries are
answered only by name, fetched lazily and cached for 5 minutes — they never
appear in the omitted-service answer. Only statusPageUrls pages are named
in the tool's description text, in a "Monitored services" sentence, since
that sentence describes what an omitted service returns; the service
argument's enum is wider and lists every configured name across both keys,
which is how a queryable page gets asked for by name at all. The tool leaves
the request only when both keys are empty, rather than shipping a tool that
could only answer "not configured".
Verse is a strict subset of chat: everything chat hides, plus
set_reminder, schedule_llm_task and check_service_status, none of
which has an in-character use. The subset relation is asserted by
test_verse_profile_is_strict_subset_of_chat.
On any turn in a verse-enabled channel the model also receives the verse
tool set, advertised to every speaker whether or not they have opted in,
so the channel's cacheable prompt prefix stays byte-identical across
users; calls from a speaker with no avatar land on denial handlers. The
set is verse_act, verse_move, verse_look, verse_recall,
verse_record, verse_edit (refused unless the caller holds
llm.verse.edit), and verse_storybook (present only when
verseStorybookEnabled is on). See
verse operations.
Scheduled LLM tasks¶
schedule_llm_task lets the assistant create recurring agentic work
("every weekday at 09:00, summarise the news"). The
bridgeScheduledTaskLimit key (default 5, 0 disables scheduling) caps
active schedules per creator in a channel. Each fire still counts
against the user's normal @ask rate-limit bucket; the cap limits the
number of pending schedules, not their cumulative cost. Owners can
list and cancel any user's tasks with @remind admin.
Operator config¶
| Setting | Default | Description |
|---|---|---|
bridgeEnabled |
False |
Primary switch, per channel. False removes every bridge tool. |
bridgeAllowedPlugins |
empty (curated default applies) | Space-separated plugin names. A non-empty value replaces the curated set entirely. Names use camel case to match cb.name(). |
bridgeAllowMutating |
False |
Per channel. Exposes commands listed in MUTATING_COMMANDS. |
bridgeScheduledTaskLimit |
5 |
Per-creator cap on active schedule_llm_task entries. 0 disables scheduling. |
pendingTasksEnabled |
False |
Per channel. Advertises set_reminder and schedule_llm_task on the chat surface, so the model can create reminders and schedules from plain language. Listing and cancelling stay command-only. |
bridgeDebugInChannel |
False |
Per channel. Appends a one-line footer listing every bridge call made during the turn, as Plugin.command (N chars) [ok], or [err:<reason>] when the call was refused. Argument text is never echoed, only its length: bridge arguments are model-generated and can carry secrets. Full arguments go to the DEBUG log. |
To add a plugin outside the curated default, list every plugin you want
exposed, because the registry value replaces the default rather than
extending it. For example, to add Factoids and Dict:
@config plugins.LLM.bridgeAllowedPlugins Misc Time Math Utilities Seen Web Later Note Karma QuoteGrabs RSS DDG Factoids Dict
Once a plugin is in bridgeAllowedPlugins and loaded into Limnoria,
every read-only command it exports becomes available to the model on
the next turn.