| name | langbot-plugin-dev |
| description | Develop, debug, and test LangBot plugins. Use when creating new LangBot plugins, fixing plugin bugs, setting up a LangBot test environment, or testing plugins via WebSocket. Covers plugin component architecture (EventListener, Command, Tool), the plugin SDK API (invoke_llm, get_llm_models, send_message, plugin storage), common pitfalls, and automated WebSocket-based testing. Triggers on "langbot plugin", "lbp", "GroupChatSummary", "plugin debug", "langbot test". |
LangBot Plugin Development & Debugging
Controlling a running instance via MCP
Beyond writing code, you can drive a live LangBot instance over MCP โ no raw
HTTP needed. Two MCP servers exist (both reuse existing API keys; see AGENTS.md):
- LangBot instance โ
http://<host>:5300/mcp (auth: web-UI lbk_ key or the
api.global_api_key from config.yaml). Manage bots, pipelines, models,
knowledge bases, and skills. See the langbot-mcp-ops skill.
- LangBot Space marketplace โ
https://space.langbot.app/mcp (auth: Personal
Access Token). Search plugins / MCP servers / skills. See the
langbot-space-ops skill.
Any change to an agent-accessible HTTP API endpoint must keep the matching MCP
tool and these skills in sync.
Plugin Architecture
A LangBot plugin consists of:
MyPlugin/
โโโ manifest.yaml # Plugin metadata, config schema
โโโ main.py # BasePlugin subclass (entry point, shared state)
โโโ components/
โ โโโ event_listener/ # Hook pipeline events
โ โ โโโ collector.yaml
โ โ โโโ collector.py
โ โโโ commands/ # !command handlers
โ โ โโโ mycommand.yaml
โ โ โโโ mycommand.py
โ โโโ tools/ # LLM function-call tools
โ โโโ mytool.yaml
โ โโโ mytool.py
Each component has a .yaml (metadata) and .py (implementation).
README & i18n convention (enforced on the marketplace)
A plugin published to LangBot Space serves a localized README on its detail page.
The resolver (langbot-space PluginService.GetPluginREADME) works like this:
- Root
README.md MUST be in English. It is the default and the fallback โ
when no per-language README matches the viewer's locale, the page serves the
root README.md. A non-English root README makes the English/default view show
the wrong language.
- All other languages live under
readme/README_{lang}.md โ e.g.
readme/README_zh_Hans.md, readme/README_ja_JP.md. The 8 supported locales:
en_US, zh_Hans, zh_Hant, ja_JP, th_TH, vi_VN, es_ES, ru_RU.
manifest.yaml metadata.label / metadata.description should carry the same
8-locale i18n set (repository must be a real, alive URL).
MyPlugin/
โโโ manifest.yaml
โโโ README.md # English (default + fallback) โ REQUIRED, must be English
โโโ readme/
โโโ README_zh_Hans.md
โโโ README_zh_Hant.md
โโโ README_ja_JP.md
โโโ README_th_TH.md
โโโ README_vi_VN.md
โโโ README_es_ES.md
โโโ README_ru_RU.md
manifest.yaml (incl. repository) is the source of truth โ the marketplace
syncs from it, so edit the package and re-publish rather than patching live data.
Critical SDK Pitfalls
1. MessageChain is a RootModel โ iterate directly
for component in event.message_chain.components:
for component in event.message_chain:
2. Message.content must be list[ContentElement] or str, not a single ContentElement
from langbot_plugin.api.entities.builtin.provider import message as provider_message
Message(role="user", content=ContentElement.from_text("hello"))
Message(role="user", content=[ContentElement.from_text("hello")])
Message(role="user", content="hello")
3. invoke_llm does NOT accept timeout
await self.invoke_llm(llm_model_uuid=uuid, messages=msgs, timeout=60)
await self.invoke_llm(llm_model_uuid=uuid, messages=msgs)
4. invoke_llm response.content can be str OR list
response = await self.invoke_llm(...)
if response.content:
if isinstance(response.content, str):
return response.content
elif isinstance(response.content, list):
parts = [e.text for e in response.content if hasattr(e, "text") and e.text]
return "\n".join(parts)
5. get_llm_models() returns UUIDs
models = await self.get_llm_models()
model_uuid = models[0]
Known bug (v4.9.3): The host handler may return list[dict] instead of list[str]. If you hit TypeError: unhashable type: 'dict' in invoke_llm, the fix is in LangBot/src/langbot/pkg/plugin/handler.py โ change 'llm_models': llm_models to 'llm_models': [m['uuid'] for m in llm_models].
6. invoke_llm parameter is llm_model_uuid, NOT model_uuid
await self.invoke_llm(messages=msgs, model_uuid=uuid)
await self.invoke_llm(messages=msgs, llm_model_uuid=uuid)
7. prevent_default() alone does NOT block LLM response
To fully prevent the default LLM pipeline from responding when your EventListener handles the message, you must call both:
event_context.prevent_default()
event_context.prevent_postorder()
Using only prevent_default() still allows the LLM to generate a response.
8. get_plugin_storage / set_plugin_storage may throw KeyError: 'owner'
This is a version mismatch between the SDK and host. Wrap storage calls in try/except:
try:
data = await self.get_plugin_storage("my_key")
except Exception:
data = None
9. Component YAML must have full structure, not just name/description
name: translator
description:
en_US: 'Does stuff'
apiVersion: v1
kind: EventListener
metadata:
name: translator
label:
en_US: Translator
spec:
execution:
python:
path: translator.py
attr: Translator
10. BasePlugin import path
from langbot_plugin.api.definition.base_plugin import BasePlugin
from langbot_plugin.api.definition.plugin import BasePlugin
Pipeline Events
Events the EventListener can hook (from most general to most specific):
| Event | When |
|---|
GroupMessageReceived | Any group message arrives (before trigger rules) |
PersonMessageReceived | Any private message arrives |
GroupNormalMessageReceived | Group message passes trigger rules, going to LLM |
PersonNormalMessageReceived | Private message going to LLM |
GroupCommandSent | Group message matched as command |
PersonCommandSent | Private message matched as command |
NormalMessageResponded | LLM generated a response |
PromptPreProcessing | About to build LLM context |
Key insight: *MessageReceived fires for ALL messages regardless of trigger rules. *NormalMessageReceived only fires for messages that match the pipeline's trigger rules (e.g., @bot, prefix, random%). Use *MessageReceived for message collection/logging.
EventContext API
@self.handler(events.GroupMessageReceived)
async def on_msg(event_context: context.EventContext):
event = event_context.event
event.launcher_id
event.sender_id
event.message_chain
await event_context.reply(MessageChain([Plain(text="hello")]))
event_context.prevent_default()
event_context.prevent_postorder()
Setting Up a Test Environment
Deploy via Docker (GitOps + Portainer)
See references/test-env-setup.md for full deployment steps.
Quick summary:
- Create
docker-compose.yaml in server-deploy repo
- Deploy via Portainer git repository method
- Set up admin account via
/api/v1/user/init POST
- Configure LLM provider and model via API
- Copy plugin to
data/plugins/ directory
WebSocket Testing
LangBot's WebUI chat uses WebSocket. Connect to test message flow:
ws://<host>:<port>/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=group
session_type=group for group chat simulation
session_type=person for private chat (always triggers pipeline)
Requires Origin header to pass CORS:
const ws = new WebSocket(url, {
headers: { Origin: 'https://your-langbot-domain' }
});
Send messages:
{"type": "message", "message": [{"type": "Plain", "text": "hello"}]}
Receive:
{"type": "connected", ...} โ connection established
{"type": "user_message", "data": {...}} โ echo of sent message
{"type": "response", "data": {"content": "...", "is_final": true/false}} โ bot reply (streamed)
Group Trigger Rules
Group messages only enter the pipeline if trigger rules are met:
{
"group-respond-rules": {
"at": true,
"prefix": ["ai"],
"random": 0.0,
"regexp": []
}
}
For testing, set random: 1.0 via PUT /api/v1/pipelines/<uuid> to respond to all messages.
Important: EventListener hooks like GroupMessageReceived fire regardless of trigger rules. Only the LLM processing (GroupNormalMessageReceived and beyond) requires trigger rules.
Plugin Hot-Reload
There is no hot-reload. After changing plugin files:
docker restart <runtime-container>
The main LangBot container does NOT need restart for plugin changes โ only the runtime container.
API Quick Reference
Admin Setup
curl -X POST $BASE/api/v1/user/init \
-H "Content-Type: application/json" \
-d '{"user":"admin@test.com","password":"test123"}'
curl -X POST $BASE/api/v1/user/auth \
-H "Content-Type: application/json" \
-d '{"user":"admin@test.com","password":"test123"}'
Provider & Model Setup
curl -X POST $BASE/api/v1/provider/providers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"MyProvider","requester":"new-api-chat-completions","base_url":"https://api.example.com/v1","api_keys":["sk-xxx"]}'
curl -X POST $BASE/api/v1/provider/models/llm \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"gpt-4o-mini","provider_uuid":"<uuid>","abilities":["chat","tool-use"]}'
curl $BASE/api/v1/provider/models/llm -H "Authorization: Bearer $TOKEN"
Pipeline Config
curl $BASE/api/v1/pipelines -H "Authorization: Bearer $TOKEN"
curl -X PUT $BASE/api/v1/pipelines/<uuid> \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '<full pipeline JSON>'
Plugin Config Types
Supported type values in manifest.yaml spec.config:
| Type | Description | Value |
|---|
string | Text input | string |
int / integer | Number input | int |
float | Decimal input | float |
bool / boolean | Toggle | bool |
select | Dropdown (needs options) | string |
prompt-editor | Multi-line prompt editor | string |
llm-model-selector | LLM model picker UI | UUID string |
bot-selector | Bot picker UI | UUID string |
Example โ let users choose which model the plugin uses:
spec:
config:
- name: model
type: llm-model-selector
label:
en_US: 'LLM Model'
zh_Hans: 'LLM ๆจกๅ'
description:
en_US: 'Select the LLM model. Falls back to first available if not set.'
zh_Hans: '้ๆฉ LLM ๆจกๅใๆช่ฎพ็ฝฎๆถไฝฟ็จ็ฌฌไธไธชๅฏ็จๆจกๅใ'
required: false
Read config in plugin code:
model_uuid = self.get_config().get("model")
Container Restart Timing
After plugin file changes, only the runtime container needs restart:
docker restart langbot-test-runtime
When to restart both (runtime first, then host):
- Added/removed Command or Tool components (host caches component lists)
- Changed
manifest.yaml structure
docker restart langbot-test-runtime
sleep 8
docker restart langbot-test
sleep 8
โ ๏ธ Do NOT restart both simultaneously โ the host may connect before plugins are mounted, causing 502 errors or missing plugin registrations.
Debugging Checklist
When a plugin doesn't work:
- Check runtime logs:
docker logs <runtime-container> โ look for mount/init errors
- Check host logs:
docker logs <langbot-container> โ look for pipeline processing errors
- Verify plugin loaded:
GET /api/v1/plugins โ should list your plugin
- Test person mode first:
session_type=person always triggers pipeline, isolating trigger rule issues
- Check trigger rules: Group mode requires @bot, prefix match, or random% to enter pipeline
- Verify model configured: Pipeline's
config.ai.local-agent.model.primary must point to a valid model UUID with working API keys
Publishing Plugins
After testing, publish via lbp publish:
cd /path/to/MyPlugin
lbp publish
This builds .lbpkg and uploads to Space marketplace as a draft. Then go to https://space.langbot.app/market to upload screenshots and submit for review.
Prerequisite: Must be logged in via lbp login --token lbpat_xxx (PAT from Space profile page).
Reference: EventListener-Only Plugin Pattern
For plugins that react to messages without commands or tools (e.g., auto-summarize URLs, collect messages, translate):
MyPlugin/
โโโ manifest.yaml # Only EventListener in spec.components
โโโ main.py # BasePlugin with shared logic (fetch, LLM calls)
โโโ components/
โ โโโ event_listener/
โ โโโ detector.yaml
โ โโโ detector.py
โโโ requirements.txt
manifest.yaml โ only declare EventListener:
spec:
components:
EventListener:
fromDirs:
- path: components/event_listener/
detector.py โ hook *MessageReceived, extract text, process, reply:
@self.handler(events.PersonMessageReceived)
async def on_msg(event_context: context.EventContext):
event = event_context.event
text_parts = []
for component in event.message_chain:
if isinstance(component, platform_message.Plain):
text_parts.append(component.text)
text = "".join(text_parts).strip()
if should_handle(text):
event_context.prevent_default()
event_context.prevent_postorder()
result = await self.plugin.process(text)
await event_context.reply(platform_message.MessageChain([
platform_message.Plain(text=result)
]))
Key: Access shared plugin logic via self.plugin (the BasePlugin instance).