| name | protobot-plugin |
| description | Guide for authoring ProtoBot plugins: the Plugin base-class contract (name/dependencies), event inventory with exact signatures, self.bot turnover rules across reconnects, hot-reload semantics, the Bot API available to plugins, and writing constraints. Use when the user asks to write, modify, or debug a plugin under plugins/, or asks how the plugin system works. |
Authoring ProtoBot Plugins
Follow this contract when writing plugins/*.py plugins. The authoritative
sources are protobot/plugin.py, protobot/session.py, protobot/client.py,
and protobot/text.py.
Minimal skeleton
from protobot import Plugin, plain_text
class MyPlugin(Plugin):
name = "my_plugin"
dependencies = ("chat_logger",)
def __init__(self):
super().__init__()
self.subscribe("player_chat", self._on_player_chat)
self.subscribe_session("session_ready", self._on_ready)
async def _on_player_chat(self, sender, name, message, chat_type_id, target):
if plain_text(message).startswith("hey,claude"):
await self.bot.send_message("1")
Event inventory (signatures match the emit sites in client.py)
Session lifecycle events (subscribe_session, on the session's own bus)
| Event | Arguments |
|---|
session_start | () |
session_connecting | (attempt: int) |
session_ready | (bot: Bot) — after connect and plugin binding |
session_disconnected | (reason: str | None, attempt: int) |
session_stop | () |
Bot protocol events (subscribe, on bot.events — same names/args as bot.on)
Chat:
system_chat (component, overlay) — component is decoded NBT (str/dict/list)
player_chat (sender_uuid|None, name, message, chat_type_id|None, target_name|None)
— sender is None for profileless chat. name and message are both chat
components, not strings — always plain_text() them; servers routinely
attach click/hover/insertion data to the name, and using it raw gives you a
dict where you expected a player name.
chat_sent (message) — this bot said something, whoever sent it (any
plugin's send_message). Use it to tell your own words from a stranger's
when the server echoes chat back as player_chat.
World/chunks:
world (WorldSessionState) · respawn (WorldSessionState)
world_ready (world) · chunk (Chunk) · chunk_unload (chunk_x, chunk_z)
chunk_batch (batch_size) · section_blocks_update (updates)
block_update (x, y, z, state_id)
Entities:
entity_add (EntityState) · entity_move (entity_id, entity|None)
entity_teleport (entity_id, entity|None, relative) · entities_remove (ids, removed)
entity_motion (entity_id, velocity, entity|None) · entity_data (entity_id, updates, entity|None)
equipment (entity_id, updates) · passengers (vehicle_id, passenger_ids)
effect_update (entity_id, effect_id, identifier, effect) · effect_remove (...)
Containers/inventory:
inventory (slot, item) · container_open (ContainerState)
container_content (ContainerState) · container_slot (ContainerState, slot)
container_close (ContainerState)
Players (tab list, i.e. who is online -- not limited to loaded chunks):
player_join (PlayerListEntry) · player_leave (PlayerListEntry) — someone
else came or went. The entry has uuid, name, game_mode, latency,
listed, display_name, and label (display name or name). The bot's own
entry never fires either event.
player_list (tuple[PlayerListEntry, ...]) — the roster the server sends
right after login. It is not a burst of player_join, so a greeter does
not welcome everyone who was already online.
player_list_unparsed (reason, payload) — the packet did not decode, so the
list was left untouched (a future action bit would misalign every name).
- Read the list any time:
bot.players (dict keyed by UUID),
bot.online_players (sorted names), bot.find_player(name).
- Protocol 774 (1.21.11) has these packet ids unverified, so no events arrive
there; write plugins that simply see nobody joining rather than assuming.
State/misc:
position (PlayerState) · abilities (PlayerAbilities) · game_mode (int)
health (health: float, food: int, saturation: float) — this bot's own health,
from set_health; bot.player.health/food/saturation hold the same values
death (message) — this bot died. message is the death-message component
(plain_text() it) or None when the signal came from health reaching 0. The
core fires it once per death (Combat Death and health≤0 are deduped via
bot.player.dead), and the bot stays on the death screen until someone calls
await bot.respawn() — see plugins/respawn.py
game_event (event_id, value) · attributes (entity_id, updates)
login (bot) · ready (bot) · reconfiguration (bot) · transfer (host, port)
error (BaseException) · close (reason: str|None)
packet (RawPacket) · packet:{state}:{id} (RawPacket) — every inbound
packet, emitted before the dedicated handler runs (not a fallback). {state}
is the state value (play), {id} is the decimal packet id, so play
packet 0x63 is packet:play:99. RawPacket has exactly state,
packet_id, payload (bytes after the id varint; nothing is pre-parsed).
path (NavigationPath, attempt) · gliding_collision (damage)
login_plugin_request / cookie_request / configuration_payload /
mod_payload / play_payload / registry are configuration-phase and
mod-loader events; ordinary plugins do not need them.
Hard rules (each one prevents a real bug)
- Handler exceptions are already isolated:
subscribe/subscribe_session
wrap every handler; an exception only prints [plugin] <name> raised while handling an event
plus a traceback and cannot drop the connection. Do not swallow
exceptions or add your own try/except inside handlers — it hides problems.
- Re-read
self.bot on every call: a reconnect spawns a fresh Bot object
and the framework rebinds subscriptions to it automatically; a cached
reference points at the closed predecessor. self.bot is None during
backoff gaps. Put per-connection state in on_bot_ready(), which fires once
per spawned bot after binding.
- Own the tasks you create in
on_enable: they outlive individual bots;
cancel them in on_disable (the framework never cancels plugin tasks).
on_enable / on_disable run once per process each. By the time
on_disable runs, your event handlers are already unbound and your exposed
functions already withdrawn, so nothing new arrives while you await.
on_bot_ready fires once per bot — including when your plugin is enabled
or hot-reloaded while a bot is already connected.
- Hot reload means a fresh instance: saving a file hot-reloads it
(
[plugins] watch, on by default) — the old instance's on_disable and the
new instance's on_enable both run again; deleting a file hot-closes it
(dependents close too). Module-level globals do not survive a reload
(each import gets a fresh module name) — persist state to a file instead.
A reload that fails (syntax error, missing dependency) is rejected and the
old plugin keeps running.
- Dependencies reference names only: the framework orders plugins with a
Kahn topological sort (deterministic, name-ordered); cycles and missing
dependencies are rejected at load. Disabling a plugin via
[plugins] disabled also disables its dependents, with a notice.
- Zero third-party dependencies: plugins may import only the stdlib and
protobot; plugin files cannot import each other (the plugin directory is
not on sys.path). Keep files UTF-8 with Chinese comments and Chinese console
output in the existing [tag] style. Log via protobot.log, not
print(): while the TUI runs, Textual captures stdout and plain prints
are lost — routes to
the TUI log area (and falls back to print outside the TUI). /
/ add / / prefixes; call
signatures match (positional args, , ).
Bot API available to plugins (public)
- Logging:
from protobot import log → log.info(*args, sep=" ", end="\n"),
log.warn(...), log.error(...), log.debug(...) — print-style calls that
reach the TUI log area (plain print() output is swallowed by the TUI)
- Chat:
await self.bot.send_message(text) / await self.bot.send_command(cmd) (leading / stripped)
- Movement:
await self.bot.tick(MovementInput()) (one 20 Hz physics tick),
walk_to(x, z, sprint=False), navigate_to(x, z, sprint=False) (A*),
set_flying(flag), start_gliding()
- Interaction:
click_container(slot, ...), close_container(), use_item(),
select_hotbar_slot(slot)
- Respawn:
await self.bot.respawn() — leave the death screen (Client Status,
action 0). Nothing else does this for you; the server never respawns a dead
player on its own. Raises UnsupportedVersion on versions where that packet
id is unverified (protocol 774), so catch it and degrade instead of retrying
- State:
bot.player (PlayerState: x/y/z, health, food, dead, yaw/pitch), bot.world
(chunks), bot.entities, bot.players (tab list) / bot.online_players /
bot.find_player(name), bot.containers, bot.session, bot.username,
bot.uuid, bot.closed (asyncio.Event), bot.disconnect_reason
- Manager:
self.manager (PluginManager, bound while the plugin is enabled):
load_order(), plugins (name → Plugin), source_of(name),
set_enabled(name, bool) (runtime toggle; disables dependents too, keeps
the source so it can be re-enabled), hot_load_file(path),
hot_reload_file(path), hot_close(name) — a plugin can list, toggle, or
hot-load other plugins (see plugins/llm_agent.py for a full example)
- Text:
from protobot.text import plain_text (str/dict/list component →
plain text). components are , not concatenated: the
arguments go into the pattern ( + →
), taken from the server's , then the built-in
table in (chat, join/leave, all vanilla death
messages), then the bare key with its arguments appended. Add server keys with
or . Also
handles the empty-key server-plugin quirk
Exposing capabilities to other plugins (and to the LLM agent)
self.expose(name, handler, *, description, parameters, llm, admin) publishes a
function as "<plugin>.<name>". Declare exposures in __init__ (same place as
subscribe); the manager publishes them when the plugin is enabled and
withdraws them on disable or hot-reload, so a stale instance can never be
called. Usable as a decorator too.
class Fishing(Plugin):
name = "fishing"
def __init__(self):
super().__init__()
self.expose("start", self._start, description="Start auto-fishing",
llm=True, admin=True)
self.expose("status", self._status, llm=True)
async def _start(self):
...
return "Auto-fishing started"
- Call another plugin:
await self.call("fishing.status"), or
await self.manager.call_service("fishing.start"). Coroutine handlers are
awaited; sync handlers work too. Missing name (plugin disabled or
hot-closed) raises PluginError, and the handler's own exceptions
propagate — service calls are deliberately not isolated, unlike event
handlers, because the caller needs to see the failure. Never cache the
handler; look it up each time.
- Offer it to the LLM:
llm=True adds it to the agent's tool list
automatically as <plugin>_<name>, with description and parameters (a
JSON Schema object for the keyword arguments) shown to the model.
admin=True makes the agent refuse it for players outside its admins
list. Nothing needs changing in llm_agent.py.
- Introspection:
manager.services(), manager.get_service(qualified),
manager.llm_services().
admin is metadata, not enforcement: call_service does not check it.
It tells agent-style callers (llm_agent) to refuse the function for
non-admins; a plugin calling self.call(...) is trusted code and gets
through regardless.
- Arguments from a model are filtered: the LLM path passes only the keys
your
parameters schema declares, so a model inventing a reason field
cannot break your signature. Declare the parameters you accept and write a
normal signature — no **kwargs needed. Plugin-to-plugin calls are strict,
so a wrong keyword there raises.
Companion files (settings, state)
Do not hand-roll settings loading. self.data_path("name.json") resolves a path
next to the plugin's own source file, and
self.settings_file(filename, DEFAULTS, label=..., normalize=...) returns a
PluginSettings that handles the whole lifecycle:
DEFAULTS = {"enabled": False, "delay": 5.0}
class Thing(Plugin):
name = "thing"
def __init__(self):
super().__init__()
self._config = None
self._settings = dict(DEFAULTS)
@staticmethod
def _normalize(merged: dict) -> dict:
merged["delay"] = max(0.5, float(merged.get("delay", 5.0)))
return merged
async def on_enable(self):
self._config = self.settings_file(
"thing.json", DEFAULTS, label="Thing", normalize=self._normalize
)
self._settings = self._config.load()
def _poll(self):
if self._config.reload_if_changed():
self._settings = ._config.data
log.info()
load() writes the defaults on first run, deep-merges user values over them
(so a user who sets one key in a section keeps the defaults for the rest),
runs your normalize, and snapshots the mtime.
reload_if_changed() returns True when the file changed on disk. Poll it from
a loop you already have; there is no need for a task per settings file.
patch({"key": value}) re-reads the file, applies just those keys, writes, and
re-snapshots — so a value the user edited meanwhile survives, keys they never
set are not expanded into the file, and your own write is not seen as an
external edit on the next poll. Use it whenever runtime state has to persist.
deep_merge(base, extra) is exported if you need it directly.
Pre-delivery checklist