Build Your First Plugin (10 minutes)
Build Your First Plugin in 10 Minutes
By the end of this tutorial you'll have a working Ciaren plugin that adds a new node to the canvas — one that runs in previews and runs, and exports to Python, exactly like a built-in node. We'll build a small "Add Greeting" node that adds a constant column.
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.toml
mkdir -p my-greeting-plugin/ciaren_greeting
cd my-greeting-plugin
touch ciaren_greeting/__init__.py
2. 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 — it changes only when the
contract (app.plugin_api) changes, not on every plugin release. The contract is
currently pre-1.0 (0.1.0-alpha.1) and makes no backward-compatibility promise:
target the exact version the backend reports and rebuild when it bumps. See
Contract versioning.
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 code
After 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 serve
Confirm it loaded:
ciaren-plugin list
# community.greeting should appear
GET /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.ciarenplugin
See Packaging & Distribution for the full publisher workflow and Plugin Security & Permissions for the trust model.
What next?
- Installing & Managing Plugins — approving, disabling, and uninstalling plugins
- Writing a Plugin — the full contract, events, and rules
- Plugin API Reference — every provider, spec, and method
- Plugins Overview — all the extension points
Built something?
Share it in Discussions — community plugins help everyone.