Plugin lifecycle
A plugin is a single Lua file that assigns a global table named plugin. The loader reads
metadata off that table, then calls whichever callbacks it finds. Nothing is registered
explicitly.
Metadata
---@type KebabPlugin
plugin = {
name = 'farm_tw', -- required; load fails without it
version = '1.0.0',
author = 'you',
description = 'Farms Troubled Warriors for Couch Potatoes',
hooks = { 'combat' }, -- optional explicit subscriptions
}
Only name is required, and it must be unique across every file in your plugins folder — a
second plugin claiming a name already taken is rejected with duplicate plugin name.
The name is also the handle you use everywhere else: what the console's scripts run farm_tw /
scripts stop farm_tw take, what identifies the plugin's row in the desktop app's Plugins
list, and the conventional value for the owner you pass to automation:EnsureCombat so that
two plugins do not fight over the combat runner.
If version is omitted it defaults to 1.0.0.
plugin must be a globallocal plugin = { ... } loads without error and then fails metadata extraction with
plugin table not found. Leave off the local.
The hooks field is almost always unnecessary. Subscriptions are inferred by scanning the
plugin table for any function whose key starts with on_. You would only set hooks
explicitly to subscribe to something you attach dynamically rather than with a named field.
One convenience: hooks = { 'combat' } expands to on_combat, on_combat_enter, and
on_combat_exit. Names are normalised, so 'combat' and 'on_combat' mean the same thing.
Lifecycle callbacks
The console spells these scripts run my_bot, scripts stop my_bot, and
scripts reload my_bot. The desktop app spells them Run, Stop, and Reload on the plugin's row
under Forge → Trainer → Plugins.
| Callback | Fires when |
|---|---|
| file body | The file is first read, at host startup or on reload. Runs once per load. |
on_load | The plugin is started. Initialise state here. |
execute(args) | Immediately after on_load on every start, and whenever something invokes the plugin explicitly. |
on_tick | Every host tick (250ms today) while running. |
on_stop | The plugin is explicitly stopped. |
on_unload | The plugin is unloaded: reload, or host shutdown. |
Three things about that table catch people out.
execute runs on every start. RunPlugin calls on_load, then calls execute if it
exists. Starting farm_tw therefore logs both farming Troubled Warrior… (from on_load)
and restarted (from execute). If you only want one of the two behaviours, put it in one
callback, not both.
Running an already-running plugin does nothing. There is no "re-run"; the call returns
immediately without touching on_load or execute. Stop it first.
on_stop does not fire on reload or shutdown. Reload and host shutdown go through
on_unload. If you have cleanup that must happen in every case — releasing combat automation
is the usual one — put it in a local function and call it from both:
local function release()
state.enabled = false
if automation ~= nil then
automation:DisableCombat({ owner = plugin.name })
end
end
plugin.on_stop = release
plugin.on_unload = release
on_load and execute are otherwise easy to confuse. on_load runs once when the plugin
starts. execute also runs when someone invokes the plugin directly — from the console,
scripts run <name> [args] passes the trailing words through. The shipped farm_tw plugin
uses it to mean "restart":
plugin.on_load = function()
reset()
log('farming ' .. TARGET_NAME .. ' with ' .. COMBAT_STRATEGY)
end
plugin.execute = function()
reset()
log('restarted')
end
What execute receives
The console builds a table with the words you typed and the currently selected client:
plugin.execute = function(args)
args = args or {}
-- args.client_id: the console's selected client alias, e.g. 'p1'
-- args.args: an array of the words after the plugin name
local words = args.args or {}
utils.Log('started with ' .. #words .. ' argument(s)')
end
scripts run my_bot fast 3 gives you args.args = { 'fast', '3' } — always strings, never
numbers. The desktop app's Run control passes no arguments, so write execute to work with
none.
Event callbacks
All of these receive a payload table, and every payload carries client_id.
| Callback | Payload fields beyond client_id |
|---|---|
on_combat_enter / on_combat_exit | — |
on_zone_change | old_zone, new_zone |
on_dialog_open | dialog_type, mobile_id, quest_id, total_pages |
on_dialog_close | — |
on_dialog_page_changed | current_page, total_pages |
on_loading_start / on_loading_end | — |
on_died | — |
on_level_up | level, old_level |
on_health_changed | current_health, max_health, old_health |
on_mana_changed | current_mana, max_mana, old_mana |
on_npc_range_entered / on_npc_range_exited | — |
on_drop | the drop's own fields inline: name, kind, quantity, time, zone, … |
on_client_connected | pid, window_title, connected, hooks_active |
on_client_disconnected | pid |
on_combat_round(args) is not a state event. It is the hook the combat runner calls when a
plugin is registered as a Lua combat strategy, and it receives a round snapshot rather than an
event payload. See combat.
Which events actually fire
Declaring a callback is not enough — the host has to be forwarding that kind of event in the first place, and the two hosts forward different sets. A callback for an event its host does not forward is registered successfully and then simply never called.
| Event | Console | Desktop app |
|---|---|---|
combat_enter, combat_exit | yes | yes |
zone_change | yes | yes |
dialog_open, dialog_close | yes | yes |
loading_start, loading_end | yes | yes |
died | yes | yes |
client_connected, client_disconnected | yes | yes |
level_up | no | yes |
health_changed, mana_changed | no | yes |
drop | yes | see below |
dialog_page_changed | no | no |
npc_range_entered, npc_range_exited | no | no |
The bottom two rows are emitted by the memory layer but are not forwarded to Lua by either host, so treat them as unavailable rather than merely host-specific.
drop is a special case. Both hosts forward it, but nothing produces it until the drop
logger is started, and only the console and a plugin can start one — the desktop app has no
control that does. So in the desktop app on_drop fires only if a plugin has itself called
c.drops:Start(). See Drops.
If you need a signal that is not delivered, poll for it in on_tick instead. Health, mana,
level, dialog state and NPC range all have direct client accessors, so a tick that compares
against the previous value gives you the same edge with at most one tick of latency.
on_health_changed and on_mana_changed, where they do fire, fire on every observed change,
which during combat is a steady stream — keep those handlers short and never log from them
unthrottled. See events.
client_id if you run more than one clientEvents are dispatched to every running plugin regardless of which client produced them. A
plugin driving p1 will happily receive p2's combat events and act on them.
The shipped plugins guard with a helper:
local function belongs_to_us(data)
if data == nil or data.client_id == nil then return true end
local c = clients:First()
if not c then return false end
return tostring(data.client_id) == tostring(c:GetID())
end
plugin.on_combat_enter = function(data)
if not belongs_to_us(data) then return end
-- ...
end
Note that this particular version treats a payload with no client_id as "ours", which is
a deliberate choice for single-client use. If you genuinely run several clients, invert that
default.
Subscriptions are a snapshot
The set of events a plugin is subscribed to is computed by scanning the plugin table for
on_* functions. That scan happens twice: once when the file loads, and once immediately
after on_load returns.
This is why assigning callbacks at file scope works, and why assigning one inside on_load
also works. Assigning one later — from on_tick, from an event handler, from a hotkey —
does not: the field is set, but the plugin was never subscribed, so nothing dispatches to it.
Use events.On when you need to attach a handler at an arbitrary time. It writes the field
and registers the subscription together:
events.On('zone_change', function(data)
utils.Log('now in ' .. tostring(data.new_zone))
end)
events.On replaces whatever was in plugin.on_zone_change. There is one handler per
event per plugin; pick either the table-field style or the events.On style for a given
event, and do not mix them.
Hotkeys
plugin.hotkeys maps a key name to a function. Each entry is copied to an on_hotkey_<key>
hook, invoked outside the tick loop when the host sees that key.
plugin.hotkeys = {
f8 = function()
state.enabled = not state.enabled
if state.enabled then enter_hunt() end
log('enabled=' .. tostring(state.enabled))
end,
}
A pause toggle like that is worth adding to anything long-running. When a bot misbehaves you want to stop it without killing its host and losing your hooks.
You can also register hotkeys imperatively with events.OnHotkey(key, fn), which is the same
mechanism and, like events.On, registers the subscription at the same time. Use the table
form for anything static.
Only the console binds a global key listener for plugin hotkeys. The desktop app has a binding
that could dispatch one, but nothing in its interface calls it, so plugin.hotkeys is
inert there today.
If your plugin needs a pause control that works in both hosts, do not rely on a hotkey. Use
the host's own Stop control, and make on_stop leave the world in a safe state.
Which keys can be bound
f1 through f12, the letters a–z, and the digits 0–9. Names are case-insensitive.
Modifier combinations are not supported — ctrl+f8 will not bind.
The console binds only the keys your loaded plugins actually declare, and rebinds on every
scripts reload. The listener is global, so a hotkey works while the game window has focus,
which is the point of a pause key.
Two things can stop a key from binding, and the console logs a warning naming it:
- a name it does not recognise
- a key the QuestTP hotkeys have already claimed (
g,k,x) while those are enabled
scripts hotkey <key> fires a plugin hotkey by hand from the console prompt, which is how you
test one without a global listener.
If a pause key appears to do nothing, check the console's startup log for a
plugin hotkeys active: line naming your key. No line means no listener started.
Errors and status
A plugin has one of three statuses: stopped, running, or error. An uncaught Lua error in
a callback sets error and records the message — visible in the console's scripts output,
or as the status badge on the plugin's row in the desktop app.
An error does not stop the plugin. The next tick calls back in as normal, so a plugin that errors every tick will keep erroring every tick, and the recorded message is only the most recent one.
Errors are not raised for ordinary failures. Every binding returns (value, err) or
(ok, err); only a genuine Lua mistake (indexing a nil, calling a non-function, arithmetic on
a string) or a deliberate error() produces this status. The one API-level exception is
reading an opt-in sub-object you never required.
Both hosts enforce a 10-second timeout per callback. A callback still running at the
deadline is cancelled, which shows up as context deadline exceeded. This matters most for
on_tick, which should return promptly — if you need to wait for something, count ticks across
invocations rather than blocking inside one:
-- wrong: blocks the tick loop for every plugin, not just this one
plugin.on_tick = function()
utils.Sleep(5000)
end
-- right: state machine across ticks
plugin.on_tick = function()
if state.wait > 0 then
state.wait = state.wait - 1
return
end
-- do the thing
state.wait = utils.Ticks(5)
end
That pattern is the core of every non-trivial plugin here. It gets its own page: Ticks and timing.
Stopping cleanly
on_stop is where you release anything you claimed. The most important one is combat
automation — if your plugin enabled it, your plugin turns it off:
plugin.on_stop = function()
state.enabled = false
if automation ~= nil then
automation:DisableCombat({ owner = plugin.name })
end
log('stopped after ' .. tostring(state.fights or 0) .. ' fight(s)')
end
Passing owner means you only disable automation you own. Without it you would stop combat
that a different plugin — or the operator, typing enable combat by hand at the console, or
running a combat action manually from the desktop app's trainer — set up on purpose.
Setting a state.enabled flag as the first line is also deliberate. The run context is
cancelled before on_stop is called, so a tick that was already in flight will find its
game calls failing with a cancellation error; the flag makes it bail out cleanly instead of
logging a wall of failures on its way out.
on_stop gets its own budget of five seconds, separate from the tick timeout, and it runs on
a background goroutine. Do not put a long recovery sequence in it — it will be cut off.
Mirror it into on_unload if the cleanup must also survive a reload.