This document describes how to develop custom plugins for Reel. For general setup and project overview, see README.md.
Plugins are workflow nodes. Each plugin implements the BasePlugin interface in plugins/base.py: plugin_type, display_name, description, plugin_category, config_schema, and execute(context, config).
There are two ways to add a plugin:
- Built-in: Code lives in the repository and is auto-discovered at startup. Add a module under
plugins/builtin/and register it in the builtin package. - Custom: Uploaded via the Admin UI or API, stored in the database with a
code_pathto the plugin file. Validated and registered in memory on upload.
- plugin_type — Unique string identifier (e.g.
log_event,conditional). Must not conflict with existing plugins. - display_name — Human-readable name shown in the workflow builder.
- description — Short description of what the plugin does.
- plugin_category — Category used to filter plugins in the workflow builder. Use one of:
campaign,sending,target_selection,email_validation,notification,data_transform,conditional,delay,capture,logging, or another existing category that fits. - config_schema — JSON Schema-like dictionary defining the plugin’s configuration. Used by the admin UI to render configuration forms.
- execute(context, config) — Method that runs the plugin. Receives the execution context and node config; must return an updated context dictionary.
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List
class BasePlugin(ABC):
@property
@abstractmethod
def plugin_type(self) -> str: ...
@property
@abstractmethod
def display_name(self) -> str: ...
@property
@abstractmethod
def description(self) -> str: ...
@property
@abstractmethod
def plugin_category(self) -> str: ...
@property
@abstractmethod
def config_schema(self) -> Dict[str, Any]: ...
@abstractmethod
def execute(self, context: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]: ...
# Optional overrides (default implementations exist)
def validate_config(self, config: Dict[str, Any]) -> Optional[List[str]]: ...
def get_branch_context_key(self) -> Optional[str]: ...
def on_error(self, error: Exception, context: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]: ...Source: plugins/base.py.
- validate_config(config) — Return a list of error strings for invalid config, or
Noneif valid. Called by the workflow engine beforeexecute. - get_required_context_keys() — Return a list of context keys the plugin expects. For documentation and tooling.
- get_provided_context_keys() — Return a list of context keys the plugin sets. For documentation and tooling.
- get_branch_context_key() — Return the context key that holds a boolean (or truthy/falsy) result for branching. If set, the engine follows the "true" or "false" connection from this node.
- get_branch_labels() — Return a dict with
"true"and"false"keys for display labels. Default is{"true": "True", "false": "False"}. - on_error(error, context, config) — Called when
executeraises. Return an updated context (e.g. with_errorset). Default implementation logs and sets_erroron context.
from typing import Dict, Any
from plugins.base import BasePlugin
class ExamplePlugin(BasePlugin):
@property
def plugin_type(self) -> str:
return "example"
@property
def display_name(self) -> str:
return "Example Plugin"
@property
def description(self) -> str:
return "Minimal plugin that passes context through. Use as a starting point."
@property
def plugin_category(self) -> str:
return "data_transform"
@property
def config_schema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"message": {
"type": "string",
"title": "Message",
"description": "Optional message to add to context"
}
}
}
def execute(self, context: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
# Add custom logic here; always return a dict
if config.get("message"):
context = context.copy()
context["example_message"] = config["message"]
return contextTo make it do something useful, add logic in execute() and/or extend config_schema. Use interpolate_string() for config values that may contain {{ variables }}.
Input
- context — Dictionary with at least
campaign,request,session, andvariables. May contain outputs from previous nodes (e.g.captured_credentials,validation_passed). For sending workflows, context also includestarget,targets, and related keys. - config — The node’s configuration: user-supplied values matching your
config_schema.
Output
- Return a new or mutated context dictionary. Do not return
None. The engine passes the returned context to the next node or uses it to build the HTTP response.
Response keys (inbound campaigns)
To drive the HTTP response, set one or more of these on the returned context:
- _response_html — HTML body.
- _response_redirect — Redirect URL (absolute or relative). Engine uses 302 unless _response_status is set.
- _response_json — JSON-serializable body (engine sends as application/json).
- _response_status — HTTP status code (default 200 for HTML/JSON, 302 for redirect).
- _response_headers — Dict of response headers.
To update the Flask session, set _set_session to a dict of key-value pairs. The executor merges these into the session after the workflow run.
Branching
If the plugin has two outcomes (e.g. pass/fail), set a context key to a boolean or truthy/falsy value (e.g. validation_passed) and implement get_branch_context_key() to return that key name. The workflow engine will follow the "true" or "false" connection from the node based on that value.
Config values often contain placeholders such as {{ request.form_data.email }} or {{ campaign.uid }}. Use the shared helper:
- interpolate_string(template, context) in shared/workflow_variables.py
Pass the string and the full context; it resolves {{ variable_name }} and dot notation for nested keys. Use it whenever you read a config string that may contain placeholders.
- Create a new module under plugins/builtin/ (e.g.
my_plugin.py). - Implement a class that inherits from
BasePluginand defines all required properties andexecute(). - Import the class in plugins/builtin/init.py and add it to
__all__. The registry discovers subclasses ofBasePluginfrom thebuiltinpackage on initialization. - Run tests:
make test-fastorpytest tests/test_plugins/ -v. Add a test intests/test_plugins/if appropriate.
- Write a single Python file containing one class that inherits from
BasePlugin. The file must be importable as a module (the registry uses plugins/registry.pyload_plugin_from_path()to load it and registers the first discoveredBasePluginsubclass). - Upload via the Admin UI (Plugins) or the API (e.g. POST with the plugin file). The plugin is validated with
validate_plugin, then stored in the database withcode_pathand registered in the in-memory registry. - Custom plugins remain available until the process restarts. They are not automatically loaded from the database on startup. After a restart, re-upload the plugin or load it from
code_pathif you add that behavior.
The admin workflow builder (admin/templates/workflow_builder.html) renders the config form from plugin.config_schema. The schema follows JSON Schema conventions with Reel-specific extensions.
- type — Must be
"object". - properties — Object mapping property names to property schemas.
- required — Array of property names that are required. UI shows a red asterisk and validates emptiness.
| Key | Purpose | UI behavior |
|---|---|---|
type |
Data type | Drives input type: string (text/textarea), integer/number (number input), boolean (checkbox), array (dynamic list), object (nested form or JSON textarea). |
title |
Field label | Shown as the label. Fallback order: title > description > property key. |
description |
Secondary text | Used as help text when title is present; otherwise as label. |
help |
Tooltip | Shown on info icon hover. helpText is an alias. |
placeholder |
Input placeholder | Placeholder text for text inputs. |
default |
Default value | Pre-filled when no value exists. Shows "(default)" badge when used. |
format |
String hint | For type: "string": "email" (email input), "uri"/"url" (url input), "textarea" (multiline textarea). |
enum |
Dropdown options | Renders a <select>. See enum format below. |
properties |
Nested object | For type: "object": renders nested form fields recursively. Without properties, falls back to JSON textarea. |
items |
Array item schema | For type: "array": defines schema for each item. If items.properties exists, each item is a mini-form. |
required |
Nested required | For nested objects: array of required nested property names. |
- Simple:
["opt1", "opt2"]— value and label are the same. - Object:
{ "value": <stored>, "label": "Display text", "description": "Tooltip" }—valueis stored in config;labelshown in dropdown;descriptionoptional.
Example (from plugins/builtin/redirect.py):
"status_code": {
"type": "integer",
"title": "HTTP Status Code",
"enum": [
{"value": 302, "label": "302 Found (Temporary)", "description": "Most common for post-action redirects."},
{"value": 301, "label": "301 Moved Permanently", "description": "Permanent redirect."}
],
"default": 302
}Use type: "object" with properties (and optional required) for nested fields. The UI renders them in an indented block. Example: plugins/builtin/conditional.py condition object with type, key, value, regex.
Use type: "array" with items. If items has properties, each array element is rendered as a mini-form with "Add Item" button. Example: plugins/builtin/validate_input.py fields array with items.properties for name, required, type, min_length, etc.
- x-optionsSource: "templates" — Renders a dynamic dropdown populated from
/api/templates. Used for template selection (e.g.template_idin plugins/builtin/render_template.py). - Union types —
type: ["string", "number"]is supported; the UI uses the first type for rendering.
"config_schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"title": "Message",
"description": "Optional message",
"placeholder": "Enter a message"
},
"mode": {
"type": "string",
"title": "Mode",
"enum": [
{"value": "simple", "label": "Simple"},
{"value": "advanced", "label": "Advanced"}
],
"default": "simple"
}
},
"required": ["message"]
}See plugins/builtin/conditional.py, plugins/builtin/log_event.py, plugins/builtin/redirect.py, and plugins/builtin/validate_input.py for more examples.
Unit test
- Get the plugin from the registry:
get_registry().get_plugin(plugin_type)orget_plugin(plugin_type). - Build a minimal
contextandconfigdict. - Call
plugin.execute(context, config)and assert on the returned context.
See tests/test_plugins/test_registry.py for a minimal plugin class and tests/test_plugins/test_builtin_plugins.py for execution patterns.
Custom plugin from path
Use a temporary file containing the plugin code and call registry.load_plugin_from_path(path) to load and register it, then run the same execute assertions.