Plugin anatomy
Directory structure
my-plugin/
├── plugin.yaml # required — manifest
├── __init__.py # required — exposes register(ctx)
├── README.md # optional — shown in 'flowly plugins list -v'
├── helpers.py # optional — your own modules
├── skills/ # optional — register_skill() targets
│ └── greet/
│ └── SKILL.md
└── assets/ # optional — images, configs, anything
└── icon.pngFlowly imports the plugin under the synthetic namespace flowly_plugins.<slug>, so multiple plugins coexist in sys.modules without collisions. The slug is derived from the plugin's key — my-plugin becomes my_plugin.
The manifest
The manifest declares your plugin's identity and what it provides. Flowly parses it before importing your code, so a malformed manifest fails loudly and never runs your register() function.
name: my-plugin # required, must be unique within plugins.enabled
version: 1.0.0 # semver-ish, displayed in 'flowly plugins list'
manifest_version: 1 # current schema version
description: "What it does, in one line."
author: "you@example.com"
kind: standalone
# Optional but recommended — declares what the plugin will register.
# Used by 'flowly plugins list' and the marketplace for display.
provides_tools:
- my_tool
- my_other_tool
provides_hooks:
- post_tool_call
- on_session_end
# Optional — environment variables your plugin needs.
# Listed here for documentation; Flowly does NOT enforce them.
requires_env:
- MY_API_KEY
- name: MY_OAUTH_SECRET
description: "OAuth secret from the X dashboard"
secret: trueplugin.json. Flowly probes plugin.yaml, plugin.yml, plugin.json in that order.The entry point
Your __init__.py must define a top-level register(ctx) function. Flowly calls it once at startup with a PluginContext instance. From there you wire up tools, hooks, slash commands, and skills.
def register(ctx):
"""Plugin entry point. Called once at startup."""
# 1. Tools — agent-callable functions
ctx.register_tool(
name="my_tool",
schema={"parameters": {"type": "object", "properties": {...}}},
handler=my_handler,
description="Does something useful",
)
# 2. Hooks — runtime behavior
ctx.register_hook("post_tool_call", on_tool_finished)
# 3. Slash commands — manual user triggers
ctx.register_command(
"my-cmd",
handler=lambda args: f"You said: {args}",
description="Echo your input",
)
# 4. Skills — explicit-load markdown templates
from pathlib import Path
ctx.register_skill(
name="my-skill",
path=Path(__file__).parent / "skills" / "my-skill" / "SKILL.md",
description="A useful template",
)Best practices
- Keep register() fast. It runs synchronously at startup; don't call APIs or read large files here. Defer that work to your tool/hook handlers.
- Use relative imports. Inside
__init__.py, writefrom .helpers import foo, notfrom my_plugin.helpers import foo. - Errors don't crash Flowly. If
register()raises, your plugin alone is disabled — the agent keeps running. Surface descriptive error messages so users can debug quickly. - Don't fight the schema. Tool parameters use JSON Schema; stick to
type: "object"at the top level withpropertiesandrequiredarrays.
Discovery & loading
Flowly scans three sources in this order, with later sources overriding earlier ones on key collision:
flowly/plugins_bundled/<name>/Ships with Flowly. Default-on — loaded unless explicitly disabled.
$FLOWLY_HOME/plugins/<name>/Per-profile user plugins. Opt-in via plugins.enabled in config.json.
./.flowly/plugins/<name>/Project-scoped. Opt-in via FLOWLY_ENABLE_PROJECT_PLUGINS=1 env var.
The plugins.disabled list overrides everything, including bundled plugins.
Lifecycle
From process start to a tool call, the path is:
flowly gateway / agent starts
│
▼
AgentLoop.__init__()
│
▼
_register_default_tools() built-in tools
│
▼
discover_and_load() find + load plugins
│
│ for each plugin:
│ parse plugin.yaml
│ import flowly_plugins.<slug>
│ call register(ctx)
│ → ctx.register_tool / hook / command / skill
│
▼
ready agent invokes plugin tools,
hooks fire on lifecycle events