Plugin system internals
Overview
The plugin system lives at flowly/plugins/. Six modules, each with a clear job:
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 APIThe 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.
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 NoneManifests 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:
- Disabled check — name in
plugins.disabled? Skip witherror: "disabled in config". - Kind check —
kind != "standalone"? Skip witherror: "kind=X not supported in v1". - Enabled check — for
user/projectsources, the name must appear inplugins.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:
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__openaiThe 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.
# 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)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:
- Bookkeeping. Every register call records ownership, so
flowly plugins listcan answer "which plugin owns this tool/hook/command?". - Adaptation.
register_tooltakes the function-based Hermes-style call signature and wraps it in aHermesToolAdapterthat satisfies Flowly's class-basedToolABC. Plugin authors don't think about it.
# 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:
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 resultFor session lifecycle, the firing sites live in AgentLoop._process_message:
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.
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:
# 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.