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

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:

# 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:

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:

# 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:

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:

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:

# 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

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:

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:

EventTrigger
PersonMessageReceivedAny private message received
GroupMessageReceivedAny group message received
PersonNormalMessageReceivedPrivate message deemed processable
GroupNormalMessageReceivedGroup message deemed processable
NormalMessageRespondedLLM response completed
PromptPreProcessingPrompt 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:

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:

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:

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:

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

Key source code:

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:

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:

# 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, or browse existing plugins on the marketplace for inspiration.