--- title: "LangBot プラグインシステム詳解:プロセス分離・イベント駆動・コンポーネント設計" description: "A source-level deep dive into LangBot 4.0's plugin architecture — process-isolated communication, event-driven hooks, a component-based extension model, and comparisons with Dify and MCP." --- この記事は LangBot Blog から同期されています。 [Blog の原文を読む](https://langbot.app/en/blog/langbot-plugin-system-deep-dive). 公開日:2026-02-23 · 著者:LangBot Team > この記事の日本語版はまだ公開されていないため、英語版を掲載しています。 ![LangBot Plugin System](/images/articles/source/images/covers/plugin-system.png) Most chatbot frameworks call their "plugin system" a glorified dynamic import of Python modules. LangBot 4.0 takes a harder but more principled approach — **every plugin runs in its own process**, communicating with the host through a structured JSON-RPC-style protocol. This article dissects the system from source code, end to end. ## Overall Architecture: A Three-Layer Process Model LangBot's plugin system consists of three cooperating process layers: ![LangBot Plugin System Architecture](/images/articles/source/images/plugin-system/architecture.png) **Each layer has a distinct responsibility:** 1. **LangBot Main Process**: Runs business logic (message pipelines, platform adapters, model invocations), connects to Runtime via `PluginRuntimeConnector`. 2. **Plugin Runtime**: The orchestration layer — discovers, launches, and manages all plugin subprocesses, routes requests from the main process to the appropriate plugin. 3. **Plugin Subprocesses**: Each plugin runs in its own Python process, communicating with Runtime via stdio pipes. ### Why Three Layers Instead of Two? The intuitive design would have the main process manage plugin processes directly. LangBot adds the Runtime layer for **deployment flexibility**: - **Local development**: Main process spawns Runtime as a child via stdio (zero config) - **Docker production**: Runtime runs as a separate container, connected via WebSocket - **Windows compatibility**: Since Windows asyncio has incomplete stdio subprocess support, it automatically falls back to WebSocket The same codebase — no config changes — adapts from development to production. ## Communication Protocol: JSON-RPC-Style Request/Response All cross-process communication runs on a unified protocol layer. The core data structures are minimal: ```python # Request class ActionRequest(pydantic.BaseModel): seq_id: int # Sequence number for matching request/response action: str # Action name data: dict # Payload # Response class ActionResponse(pydantic.BaseModel): seq_id: int code: int # 0 = success message: str data: dict chunk_status: str # "continue" | "end" (streaming support) ``` The `Handler` class is the system's core abstraction, acting as both RPC client and server: ```python class Handler: async def call_action(self, action, data, timeout=15.0) -> dict: """Actively call an action provided by the peer, wait for response""" self.seq_id_index += 1 request = ActionRequest.make_request(self.seq_id_index, action.value, data) future = asyncio.Future() self.resp_waiters[self.seq_id_index] = future await self.conn.send(json.dumps(request.model_dump())) response = await asyncio.wait_for(future, timeout) return response.data @action(SomeAction.DO_SOMETHING) async def handle_something(data: dict) -> ActionResponse: """Register an action for the peer to call""" return ActionResponse.success({"result": "ok"}) ``` **Key design points:** - `seq_id`-based request/response matching enables full-duplex concurrent calls - Streaming responses via `chunk_status` for long-running operations like command execution - Large messages auto-chunk (stdio: 16KB / WebSocket: 64KB per chunk) - File transfer uses a separate base64 chunking mechanism ### Action Enums: Clear API Contracts The system defines all cross-process calls through four enum groups: ```python # Plugin → Runtime (plugin-initiated requests) class PluginToRuntimeAction: REGISTER_PLUGIN = "register_plugin" SEND_MESSAGE = "send_message" # Send message to a platform INVOKE_LLM = "invoke_llm" # Call an LLM SET_PLUGIN_STORAGE = "set_plugin_storage" # Persistent storage # ... # Runtime → Plugin (runtime-dispatched commands) class RuntimeToPluginAction: INITIALIZE_PLUGIN = "initialize_plugin" EMIT_EVENT = "emit_event" CALL_TOOL = "call_tool" EXECUTE_COMMAND = "execute_command" SHUTDOWN = "shutdown" # ... # LangBot Main → Runtime class LangBotToRuntimeAction: INSTALL_PLUGIN = "install_plugin" EMIT_EVENT = "emit_event" LIST_TOOLS = "list_tools" # ... # Runtime → LangBot Main class RuntimeToLangBotAction: GET_PLUGIN_SETTINGS = "get_plugin_settings" SET_BINARY_STORAGE = "set_binary_storage" # ... ``` This makes API boundaries crystal clear — what a plugin can and cannot do is defined entirely by these enums. ## Plugin Lifecycle A plugin goes through these stages from installation to execution: ### 1. Discovery On startup, Runtime scans the `data/plugins/` directory: ```python async def launch_all_plugins(self): for plugin_path in glob.glob("data/plugins/*"): if not os.path.isdir(plugin_path): continue task = self.launch_plugin(plugin_path) self.plugin_run_tasks.append(task) ``` Directory names follow the `{author}__{name}` convention, each containing a `manifest.yaml` and plugin code. ### 2. Launch Runtime spawns an independent subprocess for each plugin: ```python async def launch_plugin(self, plugin_path: str): python_path = sys.executable args = ["-m", "langbot_plugin.cli.__init__", "run", "-s", "--prod"] ctrl = StdioClientController( command=python_path, args=args, working_dir=plugin_path, # Each plugin runs in its own directory ) await ctrl.run(new_plugin_connection_callback) ``` **Key detail**: The subprocess working directory is set to the plugin's own directory — natural filesystem isolation. ### 3. Registration After starting, the plugin process actively registers itself with Runtime: ```python # Runtime-side registration handler async def register_plugin(self, handler, container_data, debug_plugin=False): plugin_container = PluginContainer.from_dict(container_data) # Fetch plugin settings from the main process plugin_settings = await self.context.control_handler.call_action( RuntimeToLangBotAction.GET_PLUGIN_SETTINGS, {...} ) # Initialize the plugin (send config) await handler.initialize_plugin(plugin_settings) # Store the plugin container self.plugins.append(plugin_container) ``` ### 4. Running Once in `INITIALIZED` state, the plugin can receive events, tool calls, and command executions. ### 5. Shutdown ```python async def shutdown_plugin(self, plugin_container): # 1. Notify the plugin to shut down gracefully await plugin_container._runtime_plugin_handler.shutdown_plugin() # 2. Close the communication connection await plugin_container._runtime_plugin_handler.conn.close() # 3. Kill the subprocess if handler.stdio_process is not None: handler.stdio_process.kill() await asyncio.wait_for(handler.stdio_process.wait(), timeout=2) ``` ## Component System: Four Extension Types A LangBot plugin isn't a single hook function — it's a **component container**. A single plugin can provide multiple component types simultaneously: ### EventListener The most fundamental extension — listen for events in the message pipeline: ```python from langbot_plugin.api.definition.components.common.event_listener import EventListener from langbot_plugin.api.entities.events import PersonNormalMessageReceived from langbot_plugin.api.entities.context import EventContext class MyListener(EventListener): @EventListener.handler(PersonNormalMessageReceived) async def on_person_message(self, ctx: EventContext): event = ctx.event # Modify the user message before it reaches the LLM event.user_message_alter = "Answer in poetry: " + event.text_message # Or block further processing # ctx.prevent_default() # ctx.prevent_postorder() ``` Supported events cover the full message lifecycle: | Event | Trigger | |-------|---------| | `PersonMessageReceived` | Any private message received | | `GroupMessageReceived` | Any group message received | | `PersonNormalMessageReceived` | Private message deemed processable | | `GroupNormalMessageReceived` | Group message deemed processable | | `NormalMessageResponded` | LLM response completed | | `PromptPreProcessing` | Prompt preprocessing stage | Event propagation supports two interruption modes: - `prevent_default()`: Skip default behavior (e.g., skip the LLM call) - `prevent_postorder()`: Stop subsequent plugins from running ### Tool Tools for LLM Function Calling: ```python from langbot_plugin.api.definition.components.tool.tool import Tool class WeatherTool(Tool): async def call(self, params: dict, session, query_id: int) -> str: city = params.get("city", "Beijing") # Call weather API... return f"{city}: Sunny, 25°C" ``` Tool metadata (name, description, parameter schema) is defined in a companion YAML manifest file. LangBot automatically converts this into the Function definition that LLMs understand. ### Command User-triggered commands via `!command`, with subcommand support: ```python from langbot_plugin.api.definition.components.command.command import Command class MyCommand(Command): def __init__(self): super().__init__() @self.subcommand("hello", help="Say hello") async def hello(self, ctx): yield CommandReturn(text="Hello from plugin!") @self.subcommand("status", help="Show status") async def status(self, ctx): yield CommandReturn(text="All systems operational.") ``` Command results are returned via `AsyncGenerator`, providing natural streaming output. ### KnowledgeRetriever A multi-instance component for connecting external knowledge bases: ```python from langbot_plugin.api.definition.components.knowledge_retriever.retriever import KnowledgeRetriever class MyRetriever(KnowledgeRetriever): async def retrieve(self, context) -> list: results = await self.search_external_db(context.query) return [RetrievalResultEntry(content=r) for r in results] ``` KnowledgeRetriever is a **polymorphic component** — a single retriever class can spawn multiple instances, each with independent configuration. This allows users to connect multiple different external knowledge bases. ## SDK API: What Plugins Can Do Plugins gain rich capabilities through the `LangBotAPIProxy` inherited by `BasePlugin`: ```python class LangBotAPIProxy: # Message operations async def send_message(self, bot_uuid, target_type, target_id, message_chain) # Model invocation async def get_llm_models(self) -> list[str] async def invoke_llm(self, model_uuid, messages, funcs=[], extra_args={}) # Persistent storage (plugin-level isolation) async def set_plugin_storage(self, key, value: bytes) async def get_plugin_storage(self, key) -> bytes # Workspace storage (cross-plugin shared) async def set_workspace_storage(self, key, value: bytes) async def get_workspace_storage(self, key) -> bytes # System info async def get_langbot_version(self) -> str async def get_bots(self) -> list[str] async def list_plugins_manifest(self) -> list ``` **The storage API design is worth noting**: Two levels of KV storage — `plugin_storage` (plugin-private) and `workspace_storage` (globally shared), storing data as bytes (base64-serialized in transit). Simple but flexible enough. ## Event Dispatch Mechanism The complete path from main process to plugin: ![Event Dispatch Flow](/images/articles/source/images/plugin-system/event-dispatch.png) Key source code: ```python async def emit_event(self, event_context, include_plugins=None): for plugin in self.plugins: if plugin.status != RuntimeContainerStatus.INITIALIZED: continue if not plugin.enabled: continue # Pipeline-level plugin filtering if include_plugins is not None: plugin_id = f"{plugin.manifest.metadata.author}/{plugin.manifest.metadata.name}" if plugin_id not in include_plugins: continue resp = await plugin._runtime_plugin_handler.emit_event( event_context.model_dump() ) event_context = EventContext.model_validate(resp["event_context"]) # Plugin requested propagation stop if event_context.is_prevented_postorder(): break return emitted_plugins, event_context ``` The `include_plugins` parameter enables **pipeline-level plugin binding** — different message processing pipelines can use different subsets of plugins. ## Installation & Distribution Plugins support three installation sources: 1. **Local upload**: `.lbpkg` files (actually zip archives containing manifest.yaml and code) 2. **Marketplace**: Install from LangBot Space online 3. **GitHub Release**: Download from a GitHub repository's Release assets The installation flow: ```python async def install_plugin(self, source, install_info): yield {"current_action": "downloading plugin package"} # 1. Fetch and extract the plugin package (unzip) plugin_path, author, name, version = await self.install_plugin_from_file(plugin_file) yield {"current_action": "installing dependencies"} # 2. Install dependencies (pip install -r requirements.txt) pkgmgr_helper.install_requirements(requirements_file) yield {"current_action": "initializing plugin settings"} # 3. Initialize configuration await self.context.control_handler.call_action( RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS, {...} ) yield {"current_action": "launching plugin"} # 4. Launch the plugin process task = self.launch_plugin(plugin_path) ``` The entire process reports progress via `AsyncGenerator`, enabling real-time installation status in the frontend. ## Developer Experience The SDK provides a complete developer toolchain: ```bash # Initialize a new plugin lbp init # Add a component lbp component add # Run locally for debugging lbp run # Package for publication lbp publish ``` **Debug mode** has a particularly clever design: the developer's plugin connects to the running Runtime via WebSocket (instead of stdio), meaning you can hot-reload plugin code without restarting LangBot. Debug plugins are specially marked in the UI and protected from accidental deletion. ## Comparisons with Other Systems ### vs Dify Plugins Dify's plugin system (`dify-plugin-daemon`) shares the process isolation philosophy with LangBot, but the focus differs: - **Dify**: Plugins extend workflow node types (Tool, Model, Extension) — designed for AI application orchestration - **LangBot**: Plugins extend the message processing pipeline (Event, Tool, Command, KnowledgeRetriever) — designed for instant messaging scenarios LangBot's `EventListener` component provides a capability Dify lacks — injecting logic at any stage of message processing. ### vs MCP (Model Context Protocol) MCP is a standardized protocol for AI tool invocation. LangBot's Tool component and MCP services overlap functionally, but serve different purposes: - **MCP**: A universal "AI calls external capabilities" protocol, usable by any LLM application - **LangBot Tool**: Deeply integrated with message processing context, with access to session info, user identity, etc. In practice, LangBot natively supports MCP — users can configure MCP servers directly in LangBot without writing plugins. LangBot's Tool component is for scenarios requiring access to LangBot's internal context. ## Design Decisions Explained **Why process isolation instead of threads/coroutines?** - Plugin code quality is unpredictable; a segfault shouldn't crash the entire service - Dependency isolation: different plugins may depend on different versions of the same library - Resource control: you can set per-plugin process resource limits **Why JSON instead of Protobuf/MessagePack?** - Debug-friendly: developers can directly read communication logs - Natively supported in Python, no extra dependencies - The performance bottleneck isn't serialization (plugin call frequency is far below database queries) **Why stdio over WebSocket by default?** - stdio requires no network stack — lower latency - Simpler process lifecycle management (child processes auto-cleanup when parent exits) - WebSocket is only used where stdio isn't supported (Docker, Windows) ## Conclusion LangBot's plugin system is a **production-grade, process-isolated, event-driven component framework for extensibility**. Its core design principles: 1. **Safety first**: Process isolation ensures plugins can't destabilize the main service 2. **Deployment flexibility**: Dual stdio/WebSocket modes adapt to all environments 3. **Developer-friendly**: Complete SDK, CLI, and debug support 4. **Component-based**: Four component types cover the major extension needs If you're interested in developing LangBot plugins, start with the [plugin development docs](https://langbot.app/docs/en/plugin/dev/tutor), or browse existing plugins on the [marketplace](https://space.langbot.app/market) for inspiration.