Writing a Ciaren plugin
Writing a Ciaren plugin
A plugin can contribute to the catalog (nodes/connectors/model types/engines/
exporters/validators), declare capabilities and permissions, ship executable
nodes, connectors, and ML model types that run end-to-end (via NodeRuntime,
ConnectorRuntime, and ModelProvider builders), and subscribe to
lifecycle/execution events.
A plugin is a small Python package that implements the Plugin contract and
registers one or more providers. It depends only on the Ciaren plugin API
(app.plugin_api, which will publish separately as ciaren-plugin-api) — never
on Ciaren's private internals.
See a complete, runnable example in
examples/plugins/hello-node-plugin/.
1. Implement Plugin
from app.plugin_api import (
NodeProvider, NodeSpec, Plugin, PluginMetadata, PortSpec, ServiceRegistry,
)
class _MyNodes(NodeProvider):
def nodes(self) -> list[NodeSpec]:
return [
NodeSpec(
id="acme.greeting",
label="Add Greeting",
category="columns",
description="Adds a greeting column.",
provider="acme.hello",
inputs=(PortSpec(id="in"),),
outputs=(PortSpec(id="out"),),
default_config={"name": "world"},
capabilities=("node.greeting",),
)
]
class AcmePlugin(Plugin):
def metadata(self) -> PluginMetadata:
return PluginMetadata(id="acme.hello", name="Acme Hello", version="0.1.0-alpha.1")
def register(self, registry: ServiceRegistry) -> None:
registry.register_node_provider(_MyNodes())
Other provider interfaces you can register: ConnectorProvider
(executable connectors →), ModelProvider
(ML model types →), StorageProvider,
ExecutionProvider, ExporterProvider, ValidatorProvider, AIProvider,
AuthProvider, LicenseProvider.
Make the node executable (NodeRuntime)
A NodeSpec only describes a node. To make it run, ship a NodeRuntime and
return it from the provider's node_implementations(), keyed by node id. The
runtime works on pandas frames; Ciaren bridges to the active engine
(pandas/polars) automatically, so a single runtime runs on both.
from app.plugin_api import NodeRuntime
class GreetingRuntime(NodeRuntime):
def execute(self, inputs, config):
df = inputs["in"].copy()
df[config["column"]] = f"Hello, {config.get('name', 'world')}!"
return {"out": df}
# Optional: makes "Export Python" work for this node.
def to_python_code(self, input_vars, output_vars, config):
col, name = config["column"], config.get("name", "world")
return f"{output_vars['out']} = {input_vars['in']}.assign(**{{{col!r}: {f'Hello, {name}!'!r}}})"
class _MyNodes(NodeProvider):
def nodes(self): ...
def node_implementations(self):
return {"acme.greeting": GreetingRuntime()}
Once registered the node executes in runs and previews, passes graph validation,
and (if to_python_code is implemented) appears in both the pandas and polars
exports — exactly like a built-in.
Give the node a real sidebar form (config_schema)
Declare the node's form on its spec and the editor renders it — labeled inputs, selects, checkboxes, column pickers — no frontend code:
NodeSpec(
id="acme.greeting",
...,
config_schema={"fields": [
{"key": "column", "label": "New column", "type": "string", "required": True},
{"key": "name", "label": "Greet who", "type": "string", "default": "world"},
{"key": "shout", "label": "Uppercase", "type": "boolean", "default": False},
]},
)
Field types: string, number, integer, boolean, select (+ options),
string_list, and column/column_list (resolved against the columns arriving
on the node's wire). Without a schema, the editor falls back to fields inferred
from default_config, so every plugin node stays configurable.
Host services in the runtime (NodeContext)
Ciaren actually invokes execute_with_context(inputs, config, context); the
default implementation delegates to execute, so simple runtimes never notice.
Override it when you need the context:
context.in_preview— True during editor previews on sampled data; skip training/persisting and return a cheap placeholder.context.models— the MLflow-backed ModelStore for train-style nodes (see ML Model Plugins).context.permissions— the permissions the user actually granted.context.license_token— this plugin's own signed license token (raw JSON, or""). Forward it to your server to build a thin-client paid node whose logic and license check run server-side; see thin-client plugins.
Node categories
NodeSpec.category controls where the node lands in the editor palette. Use a
built-in category (input, clean, columns, reshape, analytics,
quality, chart, ml, output, or plugins) to slot it into that section.
Unknown values are normalized to plugins, so the node still renders and runs
normally without the frontend needing a new palette section.
React to events
A plugin can subscribe to lifecycle and execution hooks via registry.events
inside register(). Subscribers are error-isolated (a raising hook is logged and
skipped) and run synchronously in registration order.
from app.plugin_api import Hook
class AuditPlugin(Plugin):
def metadata(self): ...
def register(self, registry):
registry.events.subscribe(Hook.after_graph_execute, self._log_run)
def _log_run(self, *, flow_id, run_id, status, **_):
print(f"[audit] flow {flow_id} run {run_id}: {status}")
Emitted today (app.plugin_api.Hook): plugin_enabled, plugin_disabled,
before_graph_execute, after_graph_execute, before_node_execute,
after_node_execute, and export_requested. Graph-level and export hooks fire
for every run/export; node-level hooks fire in the in-process (thread)
execution path — in process mode a worker can't reach parent subscribers, so
prefer graph-level hooks for cross-mode behaviour.
Reserved (defined for a stable namespace but not emitted yet — don't rely
on them firing): plugin_installed (install runs in the CLI, a separate process),
project_created / project_opened / project_saved, graph_loaded, and
graph_validated.
2. Add a manifest
ciaren-plugin.json at the plugin directory root — see
plugin-manifest.md. The loader validates it and
checks compatibility on two independent axes before importing your code: the
Ciaren app version (ciaren specifier) and the plugin-contract version
(api_version vs the backend's PLUGIN_API_VERSION). An incompatible plugin on
either axis is rejected up front and reported in /api/plugins/diagnostics — it
never runs. The contract version bumps only when app.plugin_api changes,
independently of your plugin's own version; while it is pre-1.0 (alpha) it makes
no backward-compatibility promise, so target the exact version the backend reports
and rebuild when it bumps. See
Contract versioning.
You don't have to hand-write it. Because your Plugin already declares the id,
version, permissions, nodes, and categories, generate the manifest from the code
so the two can't drift:
ciaren-plugin manifest ./my-plugin # writes ciaren-plugin.json
ciaren-plugin manifest ./my-plugin --out - # or print it to stdout
This still ships and validates the manifest before any code runs — it only removes the duplicated, drift-prone copy.
3. Make it discoverable
Installed package — declare an entry point in pyproject.toml:
[project.entry-points."ciaren.plugins"]
acme = "acme_hello.plugin:AcmePlugin"
Local directory (no install) — drop the plugin directory under a path on
CIAREN_PLUGINS_DIR (or ~/.ciaren/plugins), with its ciaren-plugin.json
declaring an entrypoint.
4. Verify
ciaren serve
curl localhost:8055/api/plugins
curl localhost:8055/api/plugins/diagnostics # shows isolated load errors
curl localhost:8055/api/catalog/nodes # your node appears here
Rules
- Depend only on
app.plugin_api; never import Ciaren internals. - Ids must be unique. A plugin cannot shadow a core node id — the registry rejects the collision and rolls the whole plugin back.
- A failing or incompatible plugin is isolated: it shows up under
/api/plugins/diagnostics, it does not crash the app.