This documents the full API surface available to HexBot plugins. Every plugin's init() function receives a frozen PluginAPI object scoped to that plugin.
A plugin is a directory under plugins/ containing:
index.ts— source code with required exportstsup.config.ts— build config (bundles todist/index.js)config.json— optional default config valuespackage.json— optional, only if the plugin has its own npm dependencies
The plugin's index.ts exports:
import type { HandlerContext, PluginAPI } from '../../src/types.js';
export const name = 'my-plugin'; // required — alphanumeric, hyphens, underscores
export const version = '1.0.0'; // required
export const description = 'What it does'; // required
export function init(api: PluginAPI): void | Promise<void> {
// Register binds, set up state
}
export function teardown(): void | Promise<void> {
// Optional — clean up timers, connections, etc.
// Binds are automatically removed by the loader.
// Also called if init() throws partway through, so partial
// state is safely drained before the error propagates.
}All plugins bundle via tsup to a single dist/index.js. Run pnpm build:plugins to build all plugins. The loader imports dist/index.js at runtime — source .ts files are never imported directly.
Plugins with their own package.json (e.g., rss with rss-parser) have their npm dependencies installed automatically by the build script and inlined into the bundle.
Config values (config.json) are merged with (and overridden by) the plugin's entry in config/plugins.json. Plugins are auto-discovered from the plugins/ directory by checking for dist/index.js — they do not need an entry in plugins.json to be loaded. To disable a plugin, set "enabled": false in plugins.json.
By default, plugins operate in all channels. To restrict a plugin to specific channels, add a channels array to its plugins.json entry:
{
"greeter": {
"channels": ["#lobby", "#welcome"],
"config": { "message": "Welcome to {channel}, {nick}!" }
}
}When channels is set, the plugin's bind handlers only fire for events in those channels. Non-channel events (private messages, timers, nick changes, quits) always fire regardless of scope. Channel names are compared case-insensitively using the network's CASEMAPPING. An empty array ("channels": []) effectively disables the plugin for all channel events.
All properties on the API object are frozen. Plugins cannot modify the API or its nested objects.
The plugin's registered name. Matches the name export.
The merged config for this plugin. Values come from the plugin's own config.json defaults, overridden by the config key in config/plugins.json.
// plugins.json
{
"my-plugin": {
"enabled": true,
"config": {
"greeting": "Hello!"
}
}
}
// In init():
const greeting = (api.config.greeting as string) ?? 'Hi';Secrets via _env fields. Any config field named <name>_env: "VAR_NAME" is resolved from process.env before the plugin sees its config. The resolved value appears at <name>, and the _env key is removed. This works in both the plugin's own config.json and in plugins.json overrides.
// plugins/my-plugin/config.json
{
"api_key_env": "MY_PLUGIN_API_KEY",
"endpoint": "https://api.example.com"
}// In init():
const apiKey = api.config.api_key as string | undefined;
if (!apiKey) {
throw new Error('MY_PLUGIN_API_KEY env var is required');
}Plugins must never read process.env directly — declare a _env field so the secret flows through the normal config path. See docs/SECURITY.md.
Read-only, deep-frozen view of config/bot.json. Contains: irc (host, port, tls, tls_verify, tls_cert, tls_key, nick, username, realname, channels), owner (handle, hostmask), identity (method, require_acc_for), services (type, nickserv, sasl), and logging (level, mod_actions). The NickServ password is omitted from services. The channels array contains only channel name strings (keys are never exposed). The database and pluginDir filesystem paths are omitted. The chanmod key is present only for the chanmod plugin.
Read-only access to the permissions system.
Read-only access to NickServ identity verification.
Namespaced database access. All keys are scoped to this plugin automatically.
Register an event handler.
| Parameter | Type | Description |
|---|---|---|
type |
BindType |
Event type (see table below) |
flags |
string |
Required user flags. '-' = anyone. 'o' = ops. 'n|m' = owner OR master. |
mask |
string |
Pattern to match against. Meaning depends on the bind type. |
handler |
(ctx: HandlerContext) => void | Promise<void> |
The callback. |
Binds are automatically tagged with the plugin ID. On unload, all binds are removed.
api.bind('pub', '-', '!hello', async (ctx) => {
ctx.reply(`Hello, ${ctx.nick}!`);
});Remove a specific handler. Rarely needed since unload cleans up automatically.
| Type | Trigger | Mask matches against | Stackable |
|---|---|---|---|
pub |
Channel message | Exact command (case-insensitive) | No |
pubm |
Channel message | Wildcard on full text | Yes |
msg |
Private message | Exact command (case-insensitive) | No |
msgm |
Private message | Wildcard on full text | Yes |
join |
User joins | #channel nick!user@host or * |
Yes |
part |
User parts | #channel nick!user@host or * |
Yes |
kick |
User kicked | #channel nick!user@host or * |
Yes |
nick |
Nick change | Wildcard on old nick | Yes |
mode |
Mode change | #channel +/-mode or * |
Yes |
raw |
Raw server line | Command/numeric (wildcard) | Yes |
time |
Timer (interval) | Seconds as string (e.g. "60") |
Yes |
ctcp |
CTCP request | Exact CTCP type (case-insensitive, e.g. VERSION) |
Yes |
notice |
Notice received | Wildcard on text | Yes |
topic |
Topic change | Channel name wildcard | Yes |
quit |
User quit | nick!user@host wildcard |
Yes |
invite |
Bot invited | #channel nick!user@host or * |
Yes |
join_error |
Bot join failed | Error name wildcard or * |
Yes |
Non-stackable types (pub, msg) replace any previous bind on the same mask. Stackable types fire all matching handlers.
Timer binds enforce a minimum interval of 10 seconds.
Every handler receives a ctx object:
| Field | Type | Description |
|---|---|---|
nick |
string |
Source nick |
ident |
string |
Source ident (username) |
hostname |
string |
Source hostname |
account |
string | null | undefined |
IRCv3 account-tag value (see below) |
channel |
string | null |
Channel name, or null for PMs |
text |
string |
Full message text |
command |
string |
Parsed command (first word for pub/msg) |
args |
string |
Everything after the command |
reply(msg) |
function |
Reply to the channel or PM source |
replyPrivate(msg) |
function |
Reply via NOTICE to the user |
The account field carries the services account name from the IRCv3 account-tag on the inbound message:
string-- the server confirmed this account sent the message (authoritative).null-- the server confirmed the sender is not identified (authoritative).undefined-- noaccount-tagdata available (cap not negotiated, non-PRIVMSG event, or server omitted the tag). Treat as "unknown, fall back to other signals".
Send a PRIVMSG to a channel or nick.
Send a CTCP ACTION (/me style).
Send a NOTICE to a channel or nick.
Send a CTCP reply. Used to respond to CTCP requests like VERSION or TIME.
These are delegated to the IRCCommands core module, which handles mode batching and mod action logging.
Auto-audit: Every
api.op/api.deop/api.kick/api.ban/api.voice/api.devoice/api.halfop/api.dehalfop/api.invite/api.topic/api.modecall writes amod_logrow tagged withsource='plugin',plugin=<your plugin id>, andby=<your plugin id>. You don't need to callapi.audit.logfor these — the wrapper does it for free, and trying to override the source or plugin name is impossible because the factory captures them in a frozen actor object. See docs/AUDIT.md for the full action vocabulary.
Join a channel, optionally with a key.
Leave a channel with an optional part message.
Set +o on a user. Logged to mod_log.
Set -o on a user. Logged to mod_log.
Set +h on a user. Requires the bot to hold +h or +o in the channel. Not all networks support half-op — check ISUPPORT PREFIX before using.
Set -h on a user.
Set +v on a user.
Set -v on a user.
Kick a user from a channel. Logged to mod_log.
Set +b on a mask. Logged to mod_log.
Send an arbitrary MODE command. Respects the server's MODES limit by batching automatically.
api.mode('#channel', '+oo', 'nick1', 'nick2');Request the current channel modes from the server (MODE #channel with no args). The server replies with RPL_CHANNELMODEIS (324), which populates channel-state (ch.modes, ch.key, ch.limit) and fires channel:modesReady. This is automatically sent on bot join.
Set the channel topic.
Invite a user to a channel.
Change the bot's own IRC nick. Used primarily for nick recovery when the desired nick becomes available.
Every plugin gets an api.audit writer scoped to its own plugin id. Use it for non-IRC privileged events — feed mutations, lockdown state changes, threat-level escalations, anything that doesn't fit the api.irc.* shape.
api.audit.log(action: string, options?: {
channel?: string | null;
target?: string | null;
outcome?: 'success' | 'failure'; // default 'success'
reason?: string | null;
metadata?: Record<string, unknown> | null;
}): void;The factory forces source='plugin', plugin=<your id>, and by=<your id>. You cannot override these — even if you stuff them into options, they're stripped. This is the enforcement boundary that keeps a misbehaving plugin from spoofing another plugin's identity or pretending to be a non-plugin source.
Examples:
// Flood plugin: lockdown triggered
api.audit.log('flood-lockdown', {
channel: '#busy',
reason: '+R',
metadata: { mode: 'R', flooderCount: 5, durationMs: 300_000 },
});
// RSS plugin: feed added
api.audit.log('rss-feed-add', {
channel: '#news',
target: feedId,
reason: feedUrl,
metadata: { interval: 3600 },
});
// Permission-denied path
api.audit.log('rss-feed-add', {
channel: ctx.channel,
target: feedId,
outcome: 'failure',
reason: 'caller lacks +o',
});api.audit.log is wrapped in try/catch — a failed audit write never propagates an exception into your handler. The mutation is what matters; audit is best-effort.
For privileged actions that map onto an api.irc.* call, you don't need to call api.audit.log at all — the IRC wrapper auto-logs the row. Reach for api.audit.log only when the event has no IRC analogue. The full action vocabulary, plus the rules for plugin authors, lives in docs/AUDIT.md.
A plugin must not call db.logModAction directly. The scoped API doesn't expose the database directly, and the audit factory is the only supported path.
Get the state for a channel the bot is in.
interface ChannelState {
name: string;
topic: string;
modes: string; // channel mode chars, e.g. "ntsk"
key: string; // current channel key ('' if none)
limit: number; // current channel user limit (0 if none)
users: Map<string, ChannelUser>;
}Get all users in a channel as an array.
interface ChannelUser {
nick: string;
ident: string;
hostname: string;
modes: string; // e.g. "ov" for op+voice
joinedAt: number; // unix timestamp (ms)
accountName?: string | null; // NickServ account from IRCv3 account-notify/extended-join
// string = identified as this account
// null = known not identified
// undefined = no IRCv3 data available
away?: boolean; // IRCv3 away-notify state
// true = user has set an AWAY message
// false = user is explicitly back
// undefined = no away-notify data received yet
}Get the full nick!ident@host hostmask for a user in a channel. Returns undefined if the user is not found.
Register a callback that fires when channel modes are received from the server (RPL_CHANNELMODEIS). Callbacks are automatically cleaned up on plugin unload.
api.onModesReady((channel: string) => {
const ch = api.getChannel(channel);
if (ch) {
api.log(`${channel} modes=${ch.modes} key=${ch.key} limit=${ch.limit}`);
}
});Look up a user record by matching a full nick!ident@host string against stored hostmask patterns.
interface UserRecord {
handle: string;
hostmasks: string[];
global: string; // global flags, e.g. "nmov"
channels: Record<string, string>; // per-channel overrides
}Check if the user in a HandlerContext has the required flags. Supports OR with | (e.g. 'n|m'). Owner flag (n) implies all other flags.
Query NickServ to verify a user's identity. Returns { verified: false, account: null } on timeout or if services are unavailable.
Returns true if services are configured and not set to 'none'.
All database operations are scoped to the plugin's namespace. Keys from one plugin cannot collide with or access keys from another.
Retrieve a value by key.
Store a string value. Overwrites any existing value for that key.
Delete a key.
List all key-value pairs, optionally filtered by key prefix.
// Store structured data as JSON
api.db.set('user:alice', JSON.stringify({ score: 42 }));
// Retrieve and parse
const raw = api.db.get('user:alice');
if (raw) {
const data = JSON.parse(raw);
}
// List all user keys
const users = api.db.list('user:');Returns ISUPPORT values from the IRC server (e.g., MODES, PREFIX, CHANMODES, CASEMAPPING). Available after the bot connects and receives the server's 005 replies.
Build a nick!ident@hostname string from any object with those three fields. Useful for constructing hostmasks from context or channel-user objects without manual string interpolation.
const mask = api.buildHostmask(ctx); // "alice!~alice@example.com"Returns true if nick case-folds to the bot's own configured nick using the network's CASEMAPPING. Use instead of comparing against api.botConfig.irc.nick directly.
Returns the configured channel key (from config/bot.json) for a channel, or undefined if no key is configured. Uses IRC-aware case folding for the channel name comparison.
The core ban store is shared across all plugins and stored under a dedicated _bans namespace. It tracks bans set by the bot with optional expiry and sticky flags.
Store a ban record. durationMs of 0 means permanent.
Remove a ban record.
Look up a specific ban.
Get all stored bans for a channel.
Get all stored bans across all channels.
Mark a ban as sticky (will be re-applied if removed). Returns true if the ban was found and updated.
Check all bans for expiry and unset expired ones via the provided mode callback. Returns the number of bans lifted.
Migrate ban records from a plugin's old namespace to the core _bans namespace. Returns the number of records migrated.
interface BanRecord {
mask: string;
channel: string;
by: string;
ts: number;
expires: number; // 0 = permanent, otherwise unix timestamp ms
sticky?: boolean;
}Per-channel typed key/value store backed by the database. Plugins register settings with types and defaults; admins configure them at runtime with .chanset.
Register per-channel setting definitions. Takes an array of ChannelSettingDef objects. Call this once in init(). Settings are automatically unregistered on unload.
api.channelSettings.register([
{
key: 'greet_msg',
type: 'string',
default: 'Welcome, {nick}!',
description: 'Message sent on join',
},
{
key: 'auto_op',
type: 'flag',
default: false,
description: 'Auto-op flagged users on join',
},
{
key: 'max_lines',
type: 'int',
default: 5,
description: 'Maximum response lines',
},
]);Get the value of a setting for a channel. Returns the configured value, the registered default, or '' if the key is unknown.
Get a boolean (flag) setting. Returns false if not set.
Get a string setting. Returns '' if not set.
Get an integer setting. Returns 0 if not set.
Set a per-channel setting value programmatically.
Check whether a setting has been explicitly configured for a channel.
Register a callback that fires when any per-channel setting value changes. The callback receives (channel, key, value). Automatically cleaned up on unload.
api.channelSettings.onChange((channel, key, value) => {
api.log(`Setting ${key} changed in ${channel} to ${value}`);
});Register help entries for the !help command. Entries are automatically removed on unload.
api.registerHelp([
{
command: '!mycmd',
description: 'Does something fun',
usage: '!mycmd [args]',
flags: '-',
category: 'fun', // optional — defaults to pluginId
detail: ['Extra detail line shown in !help mycmd'], // optional
},
]);Retrieve all help entries registered across all plugins. Each entry includes a pluginId field identifying which plugin registered it.
IRC-aware case folding using the network's CASEMAPPING setting (rfc1459, strict-rfc1459, or ascii). Use this instead of toLowerCase() for nick/channel comparison.
Remove IRC formatting control codes (bold, color, underline, etc.) from a string.
Messages are prefixed with [plugin:<name>] and respect the bot's configured log level.
Log an info-level message.
Log a warning.
Log an error.
Log a debug message. Only visible when the bot's log level is set to debug.
Every line written via api.log / api.warn / api.error / api.debug is
offered to each connected DCC session. Whether it reaches a given session
depends on that session's .console flags. By default a plugin's lines land
in the m (bot messages) category, except for plugins whose prefix the core
already maps elsewhere (plugin:chanmod → o, plugin:greeter/plugin:seen
→ j, etc. — see docs/DCC.md#console-flags). Debug
lines only reach sessions holding the d flag; warn/error lines always
reach sessions holding w. Plugin authors do not normally need to think
about this — pick a clear log level and the category follows.
import type { HandlerContext, PluginAPI } from '../../src/types.js';
export const name = 'welcome-back';
export const version = '1.0.0';
export const description = 'Welcomes returning users';
export function init(api: PluginAPI): void {
api.bind('join', '-', '*', (ctx: HandlerContext) => {
const key = `joined:${api.ircLower(ctx.nick)}`;
const lastVisit = api.db.get(key);
if (lastVisit) {
ctx.reply(`Welcome back, ${ctx.nick}!`);
}
api.db.set(key, String(Date.now()));
});
// Clean up old records every hour
api.bind('time', '-', '3600', () => {
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
const entries = api.db.list('joined:');
for (const entry of entries) {
if (parseInt(entry.value, 10) < cutoff) {
api.db.del(entry.key);
}
}
api.log('Cleaned up stale join records');
});
}
export function teardown(): void {
// Binds are auto-removed. Clean up any non-bind resources here.
}