Anatomy

Plugin anatomy

A plugin is a directory. The minimum required content is two files: a manifest and an entry point. Anything beyond that is up to you — additional Python modules, asset files, a SKILL.md, a README, tests.

Directory structure

text
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.png

Flowly 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.

yaml
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: true
JSON also accepted
If you prefer JSON, name the file plugin.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.

python
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, write from .helpers import foo, not from 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 with properties and required arrays.

Discovery & loading

Flowly scans three sources in this order, with later sources overriding earlier ones on key collision:

1
Bundledflowly/plugins_bundled/<name>/

Ships with Flowly. Default-on — loaded unless explicitly disabled.

2
User$FLOWLY_HOME/plugins/<name>/

Per-profile user plugins. Opt-in via plugins.enabled in config.json.

3
Project./.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:

text
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