Skip to main content

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 global

local 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.

CallbackFires when
file bodyThe file is first read, at host startup or on reload. Runs once per load.
on_loadThe plugin is started. Initialise state here.
execute(args)Immediately after on_load on every start, and whenever something invokes the plugin explicitly.
on_tickEvery host tick (250ms today) while running.
on_stopThe plugin is explicitly stopped.
on_unloadThe 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.

CallbackPayload fields beyond client_id
on_combat_enter / on_combat_exit
on_zone_changeold_zone, new_zone
on_dialog_opendialog_type, mobile_id, quest_id, total_pages
on_dialog_close
on_dialog_page_changedcurrent_page, total_pages
on_loading_start / on_loading_end
on_died
on_level_uplevel, old_level
on_health_changedcurrent_health, max_health, old_health
on_mana_changedcurrent_mana, max_mana, old_mana
on_npc_range_entered / on_npc_range_exited
on_dropthe drop's own fields inline: name, kind, quantity, time, zone, …
on_client_connectedpid, window_title, connected, hooks_active
on_client_disconnectedpid

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.

EventConsoleDesktop app
combat_enter, combat_exityesyes
zone_changeyesyes
dialog_open, dialog_closeyesyes
loading_start, loading_endyesyes
diedyesyes
client_connected, client_disconnectedyesyes
level_upnoyes
health_changed, mana_changednoyes
dropyessee below
dialog_page_changednono
npc_range_entered, npc_range_exitednono

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.

Check client_id if you run more than one client

Events 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.

Hotkeys are a console feature

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 az, and the digits 09. 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.

note

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.