Skip to content

Plugin Authoring

A Graphyn plugin has two parts:

  • Runtime plugin (GraphynPlugin) — defines node specs and executors. No Compose dependency.
  • Editor plugin (GraphynEditorPlugin) — registers custom card renderers and palette categories. Depends on Compose.

Both are optional. A plugin with only a runtime part will render using the default FieldCard. A plugin with only an editor part has no executable behaviour.


1. Create the module

In settings.gradle.kts:

include(":plugins:my-plugin")

plugins/my-plugin/build.gradle.kts:

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidMultiplatformLibrary)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
}

kotlin {
    iosArm64(); iosSimulatorArm64(); jvm()
    js { browser() }
    wasmJs { browser() }
    androidLibrary {
        namespace = "com.example.graphyn.plugins.myplugin"
        compileSdk = libs.versions.android.compileSdk.get().toInt()
        minSdk = libs.versions.android.minSdk.get().toInt()
    }
    sourceSets {
        commonMain.dependencies {
            api(projects.pluginApi)
            api(projects.editorApi)   // only if you have an editor plugin
            api(projects.ui.cards)    // only if you use FieldCardFactory
        }
    }
}

2. Define node specs

A NodeSpec describes what a node does, what ports it has, and what default values it ships with.

// plugins/my-plugin/src/commonMain/.../MyPluginSpecs.kt

internal val specSummarize = NodeSpec(
    type = "myplugin.summarize",
    label = "Summarize",
    description = "Produces a short summary of a longer text.",
    category = "myplugin.text",
    inputs = listOf(
        PortSpec("text",     WorkflowType.StringType,  description = "Full text to summarize"),
        PortSpec("maxWords", WorkflowType.IntType,     description = "Maximum word count for the summary"),
    ),
    outputs = listOf(
        PortSpec("summary", WorkflowType.StringType, description = "Shortened text"),
    ),
    defaultValues = mapOf(
        "maxWords" to WorkflowValue.IntValue(50),
    ),
)

Port naming rules: - Port IDs are plain names ("text", "maxWords") — not "name:type" strings. - Match PortSpec IDs exactly when calling registerExecutor.


3. Write the runtime plugin

// plugins/my-plugin/src/commonMain/.../MyPlugin.kt

object MyPlugin : GraphynPlugin {

    override val metadata = GraphynPluginMetadata(
        id = "myplugin",
        displayName = "My Plugin",
        version = "1.0.0",
        apiVersion = GRAPHYN_PLUGIN_API_VERSION,
    )

    override fun register(registrar: GraphynPluginRegistrar) {
        registrar.registerNodeSpec(specSummarize)

        registrar.registerExecutor(specSummarize.type) { inputs ->
            val text     = (inputs["text"]     as? WorkflowValue.StringValue)?.value ?: ""
            val maxWords = (inputs["maxWords"] as? WorkflowValue.IntValue)?.value    ?: 50
            val summary  = text.split(" ").take(maxWords).joinToString(" ")
            mapOf("summary" to WorkflowValue.StringValue(summary))
        }
    }
}

The executor receives the resolved input values (after upstream nodes have run) and returns a map of output values. Output keys must match PortSpec IDs in the spec.


4. Write the editor plugin

Register a card renderer and a palette category:

// plugins/my-plugin/src/commonMain/.../MyEditorPlugin.kt

object MyEditorPlugin : GraphynEditorPlugin {

    override val metadata = GraphynEditorPluginMetadata(
        id = "myplugin.editor",
        displayName = "My Plugin Editor",
        version = "1.0.0",
        apiVersion = GRAPHYN_EDITOR_PLUGIN_API_VERSION,
    )

    override fun register(registrar: GraphynEditorPluginRegistrar) {
        // Use the built-in FieldCard with 2 input rows and 1 output row
        registrar.registerCanvasCard(
            specSummarize.type,
            FieldCardFactory(inputRows = 2, outputRows = 1),
        )

        // Register a palette category with a colour (ARGB long)
        registrar.registerCategory(
            "myplugin.text",
            NodeCategoryMeta("Text AI", 0xFF8B5CF6L),
        )
    }
}

For a fully custom card, implement NodeCanvasFactory directly. See Custom Cards.


5. Register in the app

In your bootstrap / GraphynApp equivalent:

val runtimePlugins = listOf(MyPlugin, /* other plugins */)
val editorPlugins  = listOf(MyEditorPlugin, /* other plugins */)

val pluginRegistry = DefaultGraphynPluginRegistry().apply { installAll(runtimePlugins) }
val editorRegistry = DefaultGraphynEditorPluginRegistry().apply { installAll(editorPlugins) }

Or if you're using GraphynBootstrap from app/app, add them to GraphynDemoPlugins.


6. Test the plugin

// plugins/my-plugin/src/commonTest/.../MyPluginTest.kt

class MyPluginTest {
    @Test
    fun summarize_truncates_to_max_words() {
        val registrar = TestPluginRegistrar()
        MyPlugin.register(registrar)

        val executor = registrar.executorFor("myplugin.summarize")
        val output = runTest {
            executor(mapOf(
                "text"     to WorkflowValue.StringValue("one two three four five six"),
                "maxWords" to WorkflowValue.IntValue(3),
            ))
        }

        assertEquals("one two three", (output["summary"] as WorkflowValue.StringValue).value)
    }
}

Do's and Don'ts

Do Don't
Model a real, named operation in each spec Add placeholder or demo-only specs to a plugin
Put demo workflows in app/app Define sample WorkflowDefinition data inside a plugin
Keep plugin-api and editor-api separate Import Compose in a runtime-only plugin
Use OpaqueType for untyped pass-through ports Use OpaqueType as a shortcut to avoid thinking about types