Build Your First Plugin in 10 Minutes
Tutorial for Python developers new to Ciaren plugins. You get: a working plugin node that previews, runs, and exports to Python like a built-in node.
Follow the steps in order; each one builds on the last. We'll build a small "Add Greeting" node that adds a constant column. When you need more than the steps cover, Writing a Plugin explains how the parts fit, and the Plugin API Reference lists every class and field.
This mirrors the runnable example in the repo at
examples/plugins/hello-node-plugin/
— open it alongside this page if you'd like the finished version.
What you'll learn
The full path a plugin travels: Plugin → provider → node spec → runtime →
discovery → canvas. Once you've done it once, every other extension point
(connectors, engines, exporters, …) follows the same shape.
Plugins run unsandboxed
A plugin is Python that runs with your account's access — install only plugins you trust and can read. See Plugin Security.
Prerequisites
- Ciaren installed and runnable (
ciaren serveworks). See Installation. - Python 3.12+.
- A plugin depends only on the public plugin API (
app.plugin_api) and pandas — never on Ciaren's internals.
1. Create the package
Make a folder with a Python package inside it:
my-greeting-plugin/
├── ciaren_greeting/
│ ├── __init__.py
│ └── plugin.py
├── ciaren-plugin.json
└── pyproject.tomlmkdir -p my-greeting-plugin/ciaren_greeting
cd my-greeting-plugin
touch ciaren_greeting/__init__.py2. Implement the plugin
A plugin contributes nodes through a NodeProvider. To make the node run
(not just appear in the catalog), the provider also hands Ciaren a
NodeRuntime keyed by node id.
Put this in ciaren_greeting/plugin.py:
from __future__ import annotations
from typing import Any
from app.plugin_api import (
NodeProvider,
NodeRuntime,
NodeSpec,
Plugin,
PluginMetadata,
PortSpec,
ServiceRegistry,
)
PLUGIN_ID = "community.greeting"
NODE_ID = "greeting.add"
class AddGreetingRuntime(NodeRuntime):
"""Adds a constant greeting column to the input frame."""
def validate_config(self, config: dict[str, Any]) -> None:
if "column" in config and not str(config["column"]).strip():
raise ValueError("greeting.add: 'column' must not be empty")
def execute(self, inputs: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
df = inputs["in"].copy()
column = config.get("column") or "greeting"
df[column] = f"Hello, {config.get('name') or 'world'}!"
return {"out": df}
def to_python_code(self, input_vars, output_vars, config) -> str:
column = config.get("column") or "greeting"
greeting = f"Hello, {config.get('name') or 'world'}!"
return f"{output_vars['out']} = {input_vars['in']}.assign(**{{{column!r}: {greeting!r}}})"
class _GreetingNodeProvider(NodeProvider):
def nodes(self) -> list[NodeSpec]:
return [
NodeSpec(
id=NODE_ID,
label="Add Greeting",
category="columns",
description="Adds a constant greeting column.",
provider=PLUGIN_ID,
version="0.1.0-alpha.1",
inputs=(PortSpec(id="in"),),
outputs=(PortSpec(id="out"),),
default_config={"column": "greeting", "name": "world"},
)
]
def node_implementations(self) -> dict[str, Any]:
return {NODE_ID: AddGreetingRuntime()}
class GreetingPlugin(Plugin):
def metadata(self) -> PluginMetadata:
return PluginMetadata(
id=PLUGIN_ID,
name="Greeting Plugin",
version="0.1.0-alpha.1",
publisher="community",
description="Adds one node that writes a greeting column.",
)
def register(self, registry: ServiceRegistry) -> None:
registry.register_node_provider(_GreetingNodeProvider())That's the whole plugin: a runtime (what runs), a provider (what's in the
catalog), and a Plugin that registers the provider.
3. Add a manifest
The loader validates a manifest before importing any plugin code. Create
ciaren-plugin.json:
{
"id": "community.greeting",
"name": "Greeting Plugin",
"version": "0.1.0-alpha.1",
"publisher": "community",
"description": "Adds one node that writes a greeting column.",
"ciaren": ">=0.1",
"api_version": "0.1.0-alpha.1",
"entrypoint": "ciaren_greeting.plugin:GreetingPlugin",
"permissions": [],
"capabilities": ["node.greeting"],
"ui": { "nodes": ["greeting.add"] },
"trust": "community"
}See the Plugin Manifest reference for every field.
Three versions, don't confuse them
version is this plugin's release. ciaren is which app builds it runs on.
api_version is the plugin contract it targets. For this tutorial, keep the
values above; Contract versioning
explains when each one changes.
Don't hand-write it — generate it
Your plugin's code already declares all of this. Generate the manifest from it so the two never drift:
# The first time (no manifest yet) tell it which class is the Plugin:
ciaren-plugin manifest ./my-greeting-plugin \
--entrypoint ciaren_greeting.plugin:GreetingPlugin
# writes ciaren-plugin.json from the codeAfter the manifest exists, --entrypoint is optional — later runs reuse the
entrypoint already recorded in ciaren-plugin.json, so regenerating after a code
change is just ciaren-plugin manifest ./my-greeting-plugin.
The manifest is still shipped in the package and validated before any code
runs — generating it just keeps a single source of truth in Python. It stamps
api_version with the SDK version you built against, which is what you want during
alpha (rebuild when the contract bumps).
4. Load it (no install needed)
The fastest loop during development: point Ciaren at the folder that contains your plugin and start the server.
export CIAREN_PLUGINS_DIR=/path/to # the directory that holds my-greeting-plugin/
ciaren serveConfirm it loaded:
ciaren-plugin list
# community.greeting should appearGET /api/plugins now lists community.greeting — but a freshly discovered
plugin is never loaded automatically, even one that declares "permissions": []
like this one, so this plugin needs approval before its code runs. See
Installing & Managing Plugins to approve it; once
approved, Add Greeting appears in the node palette under the columns
category.
5. Try it on the canvas
- Open the editor, create a flow, and add a File Input (File type: CSV) with any small file.
- Drag in Add Greeting and wire the input into it.
- Preview — you'll see the new
greetingcolumn. - Export → Python — the node emits the
assign(...)line fromto_python_code, so the exported script runs without Ciaren.
You just built a plugin node that runs end-to-end. 🎉
6. Package it (optional)
To share it, add a pyproject.toml declaring the discovery entry point…
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ciaren-greeting-plugin"
version = "0.1.0-alpha.1"
requires-python = ">=3.12"
dependencies = []
[project.entry-points."ciaren.plugins"]
greeting = "ciaren_greeting.plugin:GreetingPlugin"
[tool.hatch.build.targets.wheel]
packages = ["ciaren_greeting"]…then build a portable, signable .ciarenplugin package:
ciaren-plugin keygen # one-time: a signing key
ciaren-plugin pack ./my-greeting-plugin ./greeting.ciarenplugin
ciaren-plugin sign ./greeting.ciarenplugin \
--key <private_hex> --key-id greeting-2026 --publisher community
ciaren-plugin install ./greeting.ciarenpluginSee Packaging & Distribution for the full publisher workflow and Plugin Security & Permissions for the trust model.
What next?
- Writing a Plugin — the guide: config forms, host services, events, manifests, discovery, and rules
- Plugins Overview — find the page for your next task, such as connectors, ML model types, or signing
Built something?
Share it in Discussions — community plugins help everyone.