Skip to content

PluginRouter — Splitting Large Plugins

As your plugin grows, putting all entry points in one __init__.py becomes hard to maintain. PluginRouter lets you group entry points into separate files by feature, while they still belong to the same plugin.


When you need Router

  • Your plugin has 5+ entry points
  • Different entries belong to different feature areas (e.g. "weather", "routes", "food")
  • Multiple people are working on the same plugin, each owning a module
  • Code exceeds 300 lines and you want to split files

If your plugin only has 1-3 entry points, just write them in the main file. No Router needed.


What it looks like in practice

The "Life Kit" plugin has 12 feature modules:

text
plugin/plugins/lifekit/
├── __init__.py              ← main plugin: registers all routers
├── routers/
│   ├── __init__.py          ← exports all routers
│   ├── current.py           ← current weather
│   ├── hourly.py            ← hourly forecast
│   ├── travel.py            ← travel advice
│   ├── locations.py         ← location management
│   ├── trip.py              ← route planning
│   ├── nearby.py            ← nearby search
│   ├── food.py              ← food recommendations
│   ├── recipe.py            ← recipes
│   ├── air_quality.py       ← air quality
│   ├── currency.py          ← currency conversion
│   ├── countdown.py         ← countdown
│   └── unit_convert.py      ← unit conversion
└── plugin.toml

In Plugin Manager, users see one "Life Kit" plugin with 12+ entry points. They don't need to know how the code is organized.


How to write a Router

Step 1: Create the Router file

python
# routers/countdown.py

from plugin.sdk.plugin import PluginRouter, plugin_entry, Ok, Err, SdkError


class CountdownRouter(PluginRouter):
    """Countdown feature."""

    def __init__(self):
        super().__init__(name="countdown")

    @plugin_entry(
        id="countdown",
        name="Countdown",
        description="Calculate days until a target date",
    )
    async def countdown(self, target_date: str, label: str = ""):
        # your business logic
        ...
        return Ok({"summary": f"{label} is in 30 days"})

    @plugin_entry(
        id="days_between",
        name="Days Between",
        description="Calculate days between two dates",
    )
    async def days_between(self, start_date: str = "", end_date: str = ""):
        ...
        return Ok({"summary": "100 days"})

Key points:

  • Inherit from PluginRouter
  • super().__init__(name="countdown") gives the router a name (used in debug logs)
  • Use @plugin_entry to define entries — same syntax as in the main plugin

Step 2: Register in the main plugin

python
# __init__.py

from plugin.sdk.plugin import NekoPluginBase, neko_plugin, lifecycle, Ok
from .routers import CountdownRouter, WeatherRouter

@neko_plugin
class LifeKitPlugin(NekoPluginBase):

    def __init__(self, ctx):
        super().__init__(ctx)

        # Register routers — must be in __init__
        self.include_router(CountdownRouter())
        self.include_router(WeatherRouter())

    @lifecycle(id="startup")
    async def on_startup(self):
        self.logger.info("Life Kit started")
        return Ok({"status": "ready"})

self.include_router() registers all entry points from the router under the current plugin.


What Routers can access

Once registered, a router is bound to the main plugin. You can access the main plugin's capabilities through these properties:

python
from plugin.sdk.plugin import unwrap

class MyRouter(PluginRouter):

    @plugin_entry(id="example", name="Example", description="Demo router capabilities")
    async def example(self):
        # Logging
        self.logger.info("Logging from a router")

        # Read config
        cfg = await self.config.dump()

        # Use storage
        unwrap(await self.store.set("key", "value"))

        # Call other plugins
        result = await self.plugins.call_entry("other:entry")

        # Access database
        async with unwrap(await self.db.session()) as session:
            cursor = await session.execute("SELECT * FROM notes")
            rows = cursor.fetchall()

        # Access main plugin's custom attributes/methods
        plugin = self.main_plugin
        data = await plugin.some_shared_method()

        return Ok({"done": True})
PropertySource
self.loggerMain plugin's logger
self.configMain plugin's config
self.storeMain plugin's store
self.dbMain plugin's db
self.pluginsMain plugin's plugins
self.plugin_idMain plugin's ID
self.main_pluginThe main plugin instance itself

A router is not a separate process. It runs in the same process as the main plugin and shares all resources.


Sharing logic

When multiple routers need the same utility functions, put them in the main plugin or a shared module:

text
plugin/plugins/lifekit/
├── __init__.py          ← main plugin, defines shared methods
├── _geo.py              ← shared: geolocation
├── _api.py              ← shared: API call utilities
├── _chat.py             ← shared: push messages to chat
└── routers/
    ├── current.py       ← uses self.main_plugin._resolve_location()
    └── travel.py        ← uses self.main_plugin._resolve_location()

Routers access main plugin methods via self.main_plugin:

python
class WeatherRouter(PluginRouter):

    @plugin_entry(id="get_weather", name="Get Weather", description="Look up weather")
    async def get_weather(self, city: str = ""):
        plugin = self.main_plugin
        # Call shared method on main plugin
        location, error = await plugin._resolve_location(city)
        if not location:
            return Err(SdkError(error))
        ...

Prefixed Routers

If you want to prefix all entry IDs in a router (to avoid ID conflicts):

python
self.include_router(CountdownRouter(), prefix="time_")

Now the countdown entry's actual ID becomes time_countdown.

Most of the time you don't need prefixes — just make sure entry IDs don't collide across routers.


Runtime removal

exclude_router() removes a router from the plugin's router list, but normal plugin code should not use it as a live feature toggle. Entries are collected when the host builds its dispatch table, so removing a router later does not automatically make its already-collected entries uncallable.

If you need runtime enable/disable behavior, gate the entry logic with explicit plugin configuration and rebuild/restart the plugin when its dispatch structure changes. Do not depend on the removed Extension control path.

python
# Removes from the router list only
self.exclude_router(my_router_instance)

# Same, by name
self.exclude_router("countdown")

Complete minimal example

A plugin with two routers:

python
# routers/greet.py
from plugin.sdk.plugin import PluginRouter, plugin_entry, Ok

class GreetRouter(PluginRouter):
    def __init__(self):
        super().__init__(name="greet")

    @plugin_entry(id="hello", name="Hello", description="Say hello")
    async def hello(self, name: str = "World"):
        return Ok({"message": f"Hello, {name}!"})


# routers/math.py
from plugin.sdk.plugin import PluginRouter, plugin_entry, Ok, Err, SdkError

class MathRouter(PluginRouter):
    def __init__(self):
        super().__init__(name="math")

    @plugin_entry(id="add", name="Add", description="Add two numbers")
    async def add(self, a: float, b: float):
        return Ok({"result": a + b})

    @plugin_entry(id="divide", name="Divide", description="Divide two numbers")
    async def divide(self, a: float, b: float):
        if b == 0:
            return Err(SdkError("Cannot divide by zero"))
        return Ok({"result": a / b})


# __init__.py
from plugin.sdk.plugin import NekoPluginBase, neko_plugin
from .routers.greet import GreetRouter
from .routers.math import MathRouter

@neko_plugin
class MyPlugin(NekoPluginBase):
    def __init__(self, ctx):
        super().__init__(ctx)
        self.include_router(GreetRouter())
        self.include_router(MathRouter())

This plugin shows three entry points in the panel: hello, add, divide.

Released under the MIT License.