Architecture

Plugin system internals

You don't need to know any of this to write a plugin. This page is for contributors curious about how the runtime works under the hood, and for anyone debugging deeper than the audit log allows.

Overview

The plugin system lives at flowly/plugins/. Six modules, each with a clear job:

text
flowly/plugins/
├── manifest.py    Parses plugin.yaml / plugin.json into PluginManifest
├── adapter.py     Wraps function-based tool registrations as Tool ABC
├── loader.py      Imports plugin modules under flowly_plugins.<slug> ns
├── context.py     PluginContext — the facade plugins receive
├── manager.py     PluginManager — discovery, dedup, load orchestration
└── __init__.py    Singleton accessor + public API

The system has a single global PluginManager instance, created once per AgentLoop. It owns:

  • The set of discovered plugins (loaded or not)
  • References to the live ToolRegistry + HookRegistry
  • The slash command dispatch table
  • The plugin skill index (qualified names → SKILL.md paths)

Discovery pipeline

discover_and_load() runs once at agent startup, after default tools are registered. It walks three sources in order, dedups by key, then loads each winner.

text
AgentLoop.__init__()
    │
    └─→ _register_default_tools()          # built-in tools first
            │
            └─→ get_plugin_manager()       # singleton
                    │
                    └─→ discover_and_load()
                            │
                            ├─→ _scan_dir(BUNDLED_DIR, source="bundled")
                            ├─→ _scan_dir($FLOWLY_HOME/plugins, source="user")
                            └─→ _scan_dir(./.flowly/plugins, source="project")
                                    │
                                    └─ for each child dir:
                                       find_manifest() → parse_manifest()
                                       returns PluginManifest or None

Manifests are deduplicated by key: later sources win. So a my-plugin at $FLOWLY_HOME/plugins/ overrides a like-named one in plugins_bundled/.

After dedup, each surviving manifest passes through three filters:

  1. Disabled check — name in plugins.disabled? Skip with error: "disabled in config".
  2. Kind check kind != "standalone"? Skip with error: "kind=X not supported in v1".
  3. Enabled check — for user / project sources, the name must appear in plugins.enabled. Bundled plugins skip this check (default-on).

Manifests passing all three reach _load_plugin(): import the module, call register(ctx), record tools/hooks/commands. Errors during register get caught, recorded on the LoadedPlugin, and the agent continues with the rest.

Module isolation

Plugins live in arbitrary directories. To avoid sys.modules collisions across plugins (and to give each plugin a stable import name internally), every plugin loads under a synthetic namespace:

text
Plugin path:                                  Module name:
~/.flowly/plugins/my-plugin/                  flowly_plugins.my_plugin
flowly/plugins_bundled/disk-cleanup/          flowly_plugins.disk_cleanup
flowly/plugins_bundled/image_gen/openai/      flowly_plugins.image_gen__openai

The slug derives from the manifest key — / becomes __, - becomes _, and everything else stays. So image_gen/openai as a key produces the collision-safe flowly_plugins.image_gen__openai even when there's also an tts/openai (flowly_plugins.tts__openai) sitting in the tree.

python
# loader.py — the actual import
spec = importlib.util.spec_from_file_location(
    f"flowly_plugins.{slug}",
    plugin_dir / "__init__.py",
    submodule_search_locations=[str(plugin_dir)],
)
module = importlib.util.module_from_spec(spec)
sys.modules[module.__name__] = module
spec.loader.exec_module(module)
The submodule_search_locations is what enables relative imports inside the plugin — from .helpers import foo resolves correctly because Python knows the directory the plugin is rooted in.

PluginContext as facade

register(ctx) never touches the real ToolRegistry or HookRegistry directly — it goes through PluginContext. This indirection serves two purposes:

  1. Bookkeeping. Every register call records ownership, so flowly plugins list can answer "which plugin owns this tool/hook/command?".
  2. Adaptation. register_tool takes the function-based Hermes-style call signature and wraps it in a HermesToolAdapter that satisfies Flowly's class-based Tool ABC. Plugin authors don't think about it.
python
# context.py
class PluginContext:
    def register_tool(self, *, name, schema, handler, check_fn=None, description=""):
        adapter = HermesToolAdapter(
            name=name, schema=schema, handler=handler,
            check_fn=check_fn, description=description,
        )
        self._manager._tool_registry.register(adapter)
        self._manager._plugin_tool_names.setdefault(
            self.manifest.name, set()
        ).add(name)

Hook firing path

Hooks fire from a handful of well-defined call sites. For tool lifecycle events:

text
Agent makes a tool call
    │
    └─→ ToolRegistry.execute(name, params)
            │
            ├─→ fire_pre_tool(ToolHookContext)
            │     └─ if any callback returns BlockAction → short-circuit
            │
            ├─→ tool.execute(**params)         # the actual tool body
            │
            ├─→ fire_post_tool(ctx with result/duration/success)
            │
            └─→ fire_transform_tool_result(ctx)
                  └─ if any callback returns str → use that as the new result

For session lifecycle, the firing sites live in AgentLoop._process_message:

text
Inbound message arrives
    │
    └─→ _process_message(msg)
            │
            ├─ first time we see msg.session_key?
            │     └─→ fire_session_start(SessionHookContext)
            │
            ├─→ tools.set_active_session(msg.session_key)   # stays bound for the turn
            │
            ├─→ _process_message_inner(msg)                  # actual work
            │
            └─ finally:
                  ├─→ tools.set_active_session("")
                  └─→ fire_session_end(...)

Every callback runs inside its own try/except. A misbehaving hook gets logged via logger.exception and its return is treated as None — the loop continues with the next callback in registration order.

Tool dispatch

Plugin-registered tools are first-class — they sit in the same ToolRegistry as built-ins and follow the identical dispatch path. The agent has no way to tell a plugin tool from a built-in.

The HermesToolAdapter handles the call signature mismatch. Plugins register (name, schema, handler) functions; the adapter exposes name / description / parameters / async execute(**kwargs) — the four things Tool ABC requires.

python
class HermesToolAdapter(Tool):
    @property
    def name(self): return self._name
    @property
    def description(self): return self._description
    @property
    def parameters(self): return self._parameters

    async def execute(self, **kwargs):
        if self._check_fn and not self._check_fn():
            return f"Error: {self._name} unavailable"
        result = self._handler(**kwargs)
        if inspect.isawaitable(result):
            result = await result
        return str(result) if result is not None else ""

check_fn runs at dispatch time, not register time, so a tool gated on an OAuth token doesn't fail plugin load when the token isn't set yet.

Slash command dispatch

Slash commands flow through an entirely separate path that bypasses the LLM. In AgentLoop._process_message_inner:

python
# Built-in slash commands first (handled inline)
if is_command and command in ("new", "clear", "compact", "help"):
    return handle_builtin(...)

# Plugin slash commands — same parser, different dispatch
if not is_command and msg.content.strip().startswith("/"):
    parts = msg.content.strip().split(None, 1)
    plugin_cmd = parts[0][1:].lower()
    plugin_args = parts[1] if len(parts) > 1 else ""

    handler = self._plugin_manager.get_slash_handler(plugin_cmd)
    if handler:
        result = handler(plugin_args)
        if hasattr(result, "__await__"):
            result = await result
        return OutboundMessage(content=str(result), ...)

Built-in commands take priority — plugins can't shadow /help or /clear. Reserved-name conflicts are caught at registration time, before the handler ever lands in the dispatch table.

The handler runs synchronously from the dispatcher's perspective (await if it's a coroutine, otherwise direct call). There's no tool registry round-trip, no LLM call, no token spend. Plugin slash commands are the cheapest way to add deterministic functionality to a plugin.