Node-based workflow editor for Kotlin Multiplatform
Drop a canvas into any app — AI pipelines, shader graphs, automation builders, game tools.
Graphyn is a Kotlin Multiplatform library that gives your app a fully-featured node-based canvas — the kind used in Unreal Blueprint, Blender shader editor, or n8n. Wire it up with plugins to define your own node types, connect them, and execute workflows.
- Library-first — embed the canvas in your own app, not the other way around
- Plugin architecture — runtime plugins define node specs and executors; editor plugins add custom card UI
- Compose Multiplatform UI — one codebase for Android, Desktop (JVM), Web (Wasm/JS), and iOS
- MVI state model — all canvas mutations go through typed intents; easy to test and replay
- Zero Material dependency — custom design system, fully themeable
Docs site: ronjunevaldoz.github.io/graphyn-editor
- Pan & zoom with minimap
- Node editor — drag ports to connect, split/merge workflows
- Plugin system — runtime plugins (node specs + executors) and editor plugins (custom UI)
- Parallel execution — independent nodes run concurrently
- Live streaming —
executeAsFlow()for real-time progress - Persistence — auto-save to file (JVM) or local storage (Web)
- AI generation — describe a workflow, Ollama drafts it onto the canvas
- Customizable shortcuts — rebind any editor action from the toolbar
- Subgraph collapse — group nodes into reusable workflows
- Inline widgets — ComfyUI/Blender-style config cards
- Theme support — light/dark mode + custom colors
- TypeScript-safe ports — validation at wire time
┌─────────────────────────────────┐
│ app/shared (Compose UI) │
│ Canvas + Editor Shell │
└────────┬──────────────┬─────────┘
│ │
┌───────▼────┐ ┌──────▼──────┐
│ editor-api │ │ plugin-api │
│ Card UI │ │ Node specs │
│ Panels │ │ Executors │
└───────┬────┘ └──────┬──────┘
│ │
┌──────▼───────────────▼──────┐
│ core/ (folder of modules) │
│ model · execution │
│ serialization · data │
└──────────────────────────────┘
ui/cards (ShapeCardFactory, FieldCardFactory)
plugins/* (node definitions + executors)
core is a folder of focused, layered modules — there is no umbrella :core module. Each consumer depends only on the submodules it uses; everything builds up from core:model.
| Module | Artifact | What |
|---|---|---|
core:model |
graphyn-core-model |
Workflow model, types, validation, registry |
core:execution |
graphyn-core-execution |
Execution engine, executors, events |
core:serialization |
graphyn-core-serialization |
Workflow document codec |
core:data |
graphyn-core-data |
Workflow stores + platform persistence |
core:designsystem |
graphyn-ui-design |
Design tokens, theme, and UI primitives |
editor-api |
graphyn-editor-api |
Canvas card + panel contracts |
plugin-api |
graphyn-plugin-api |
Node spec + executor contracts |
ui/cards |
graphyn-ui-cards |
Ready-made card shapes (Shape, Field, Circle) |
app/shared |
graphyn-editor |
Compose Multiplatform canvas + editor shell |
ai |
graphyn-ai |
LLM workflow generation (Ollama) |
runtime |
graphyn-runtime |
Convenience bundle of all first-party plugins |
server |
graphyn-ktor-plugin |
Ktor execution API + install(Graphyn) |
plugins/control |
graphyn-plugin-control |
Branch, loop, merge |
plugins/list-ops |
graphyn-plugin-list-ops |
Map, filter, reduce, sort |
plugins/types |
graphyn-plugin-types |
Type conversion and casting |
plugins/text |
graphyn-plugin-text |
Split, join, replace, template |
plugins/io |
graphyn-plugin-io |
HTTP, file read/write, path resolution |
plugins/json |
graphyn-plugin-json |
JSON parse, query, transform |
plugins/preview |
graphyn-plugin-preview |
Live output preview nodes |
plugins/sticky-notes |
graphyn-plugin-sticky-notes |
On-canvas annotation nodes |
plugins/script |
graphyn-plugin-script |
Kotlin Script eval (JVM) |
plugins/media-core |
graphyn-plugin-media-core |
FFmpeg-backed media processing (JVM) |
plugins/media-ai |
graphyn-plugin-media-ai |
TTS / STT / OCR adapters (JVM) |
plugins/gmail |
graphyn-plugin-gmail |
Gmail integration (fetch, send, reply) |
plugins/linkedin |
graphyn-plugin-linkedin |
LinkedIn profile and feed nodes |
// gradle/libs.versions.toml
[versions]
graphyn = "0.7.4"
[libraries]
graphyn-editor = { module = "io.github.ronjunevaldoz:graphyn-editor", version.ref = "graphyn" }
graphyn-ui-cards = { module = "io.github.ronjunevaldoz:graphyn-ui-cards", version.ref = "graphyn" }
graphyn-runtime = { module = "io.github.ronjunevaldoz:graphyn-runtime", version.ref = "graphyn" }
graphyn-plugin-api = { module = "io.github.ronjunevaldoz:graphyn-plugin-api", version.ref = "graphyn" }
graphyn-editor-api = { module = "io.github.ronjunevaldoz:graphyn-editor-api", version.ref = "graphyn" }
graphyn-core-model = { module = "io.github.ronjunevaldoz:graphyn-core-model", version.ref = "graphyn" }
graphyn-plugin-io = { module = "io.github.ronjunevaldoz:graphyn-plugin-io", version.ref = "graphyn" }
graphyn-plugin-gmail = { module = "io.github.ronjunevaldoz:graphyn-plugin-gmail", version.ref = "graphyn" }
# full plugin list: graphyn-plugin-{control,list-ops,types,text,io,json,preview,sticky-notes,script,media-core,media-ai,gmail,linkedin}
# full core list: graphyn-core-{model,execution,serialization,data}// build.gradle.kts
commonMain.dependencies {
implementation(libs.graphyn.editor) // full canvas UI
implementation(libs.graphyn.runtime) // all first-party plugins bundled
}| Use case | Dependency |
|---|---|
| Full canvas editor | graphyn-editor |
| All first-party plugins | graphyn-runtime |
| Card UI kit only | graphyn-ui-cards |
| Build a runtime plugin | graphyn-plugin-api |
| Build an editor plugin | graphyn-editor-api |
| Individual plugin | graphyn-plugin-{control,io,json,…} |
| Workflow model only | graphyn-core-model (+ -execution / -serialization / -data as needed) |
object MyPlugin : GraphynPlugin {
override val metadata = GraphynPluginMetadata(
id = "com.example.my",
displayName = "My Plugin",
version = "1.0.0",
)
override fun register(registrar: GraphynPluginRegistrar) {
registrar.registerNodeSpec(
NodeSpec(
type = "my.transform",
label = "Transform",
category = "com.example.text",
inputs = listOf(PortSpec("input", WorkflowType.String)),
outputs = listOf(PortSpec("output", WorkflowType.String)),
defaultValues = mapOf("input" to WorkflowValue.StringValue("hello")),
)
)
registrar.registerExecutor("my.transform") { inputs ->
val value = (inputs["input"] as? WorkflowValue.StringValue)?.value ?: ""
mapOf("output" to WorkflowValue.StringValue(value.uppercase()))
}
}
}object MyEditorPlugin : GraphynEditorPlugin {
override val metadata = GraphynEditorPluginMetadata(
id = "com.example.my.editor",
displayName = "My Editor Plugin",
version = "1.0.0",
)
override fun register(registrar: GraphynEditorPluginRegistrar) {
// Custom canvas card using ShapeCardFactory from graphyn-ui-cards
// (circle, rounded square, or any Compose Shape)
registrar.registerCanvasCard(
"my.transform",
ShapeCardFactory(
shape = CircleShape,
theme = ShapeNodeTheme(
background = { Color(0xFF6366F1) },
selectedBorder = { Color(0xFF8B5CF6) },
),
// Optional: replace initials with a custom avatar composable
avatar = { node, spec ->
AsyncImage(
url = (node.config["avatar_url"] as? WorkflowValue.StringValue)?.value,
)
}
)
)
// Custom inspector panel with editable node config
registrar.registerPanel("my.transform", EditorPanelFactory { ctx ->
val input = (ctx.selectedNode?.config?.get("input") as? WorkflowValue.StringValue)?.value ?: ""
BasicTextField(
value = input,
onValueChange = { ctx.onConfigChange("input", WorkflowValue.StringValue(it)) },
)
})
// Node category (groups nodes in the palette)
registrar.registerCategory(
"com.example.text",
NodeCategoryMeta(label = "Text", color = 0xFF6366F1L),
)
}
}ShapeCardFactory accepts any Compose Shape — CircleShape, RoundedCornerShape(12.dp), etc. Theme colors use @Composable lambdas so they can read from any CompositionLocal at render time. If no avatar is provided, the card shows the first letter of the node label.
FieldCardFactory renders each input port as an inline editable row. The widget depends on the port's WorkflowType:
WorkflowType |
Widget | Interaction |
|---|---|---|
IntType |
Stepper | − / + buttons step by 1; click value to type; only digits and − accepted |
DoubleType |
Stepper | − / + buttons step by 0.1; click value to type; digits, ., − accepted |
StringType |
Text field | Click to edit inline; any text accepted |
BooleanType |
Text field | Click to type true or false |
EnumType(values) |
Single-select dropdown | Click chip → popup list; one option selected |
MultiEnumType(values) |
Multi-select dropdown | Click chip → popup with checkboxes; multiple options |
Non-editable port types (OpaqueType, RecordType, etc.) show the port label only — no input widget is rendered.
val myNode = NodeSpec(
type = "com.example.sampler",
label = "Sampler",
inputs = listOf(
PortSpec("steps", WorkflowType.IntType),
PortSpec("cfg", WorkflowType.DoubleType),
PortSpec("prompt", WorkflowType.StringType),
PortSpec("mode", WorkflowType.EnumType(listOf("fast", "quality", "balanced"))),
PortSpec("outputs", WorkflowType.MultiEnumType(listOf("image", "latent", "preview"))),
),
defaultValues = mapOf(
"steps" to WorkflowValue.IntValue(20),
"cfg" to WorkflowValue.DoubleValue(7.0),
"prompt" to WorkflowValue.StringValue(""),
"mode" to WorkflowValue.StringValue("quality"),
"outputs" to WorkflowValue.ListValue(listOf(WorkflowValue.StringValue("image"))),
),
)@OptIn(GraphynExperimentalApi::class)
@Composable
fun App() {
val runtimeRegistry = remember {
DefaultGraphynPluginRegistry().apply { install(MyPlugin) }
}
val editorRegistry = remember {
DefaultGraphynEditorPluginRegistry().apply { install(MyEditorPlugin) }
}
val state = rememberGraphynEditorState()
// Observe workflow changes outside Compose (e.g. save to database)
LaunchedEffect(state) {
state.workflowFlow.collect { workflow ->
database.save(workflow)
}
}
GraphynEditorShell(
state = state,
dependencies = GraphynEditorShellDependencies(
nodeSpecs = runtimeRegistry.nodeSpecs,
canvasCards = editorRegistry.canvasCards,
panels = editorRegistry.panels,
categoryRegistry = editorRegistry.categories,
),
branding = GraphynBranding(appName = "My Studio"),
)
}Graphyn separates runtime concerns from editor concerns — you can ship a runtime plugin without any UI dependency.
| Layer | Module | Stable contract |
|---|---|---|
| Runtime | plugin-api |
GraphynPlugin, GraphynPluginRegistrar, NodeSpec, NodeExecutor |
| Editor cards | editor-api |
NodeCanvasFactory, GraphynEditorPluginRegistrar |
| Editor panels | editor-api |
EditorPanelFactory, EditorPanelContext (includes onConfigChange) |
| State observation | editor-api |
GraphynEditorStateView — workflow, workflowFlow: StateFlow, selectedNodeId |
Default* implementations are marked @GraphynExperimentalApi — their signatures may evolve. The interfaces above are stable.
Instead of wiring every plugin by hand, hosts on the JVM and Android can let plugins register themselves via java.util.ServiceLoader. A plugin author ships a resource file:
# src/main/resources/META-INF/services/com.ronjunevaldoz.graphyn.pluginapi.GraphynPlugin
com.example.MyPlugin
Then the host installs everything on the classpath in one call:
val registry = DefaultGraphynPluginRegistry().apply {
install(CorePlugin) // explicit, always present
installDiscovered() // plus any plugin on the classpath (ServiceLoader)
}installDiscovered() skips plugins already installed (matched by metadata.id) and returns the newly-installed ones. On JS, Wasm, and iOS it is a no-op — those hosts register plugins explicitly.
A node may embed a nested WorkflowDefinition via NodeRef.subgraph. The engine runs it recursively (and in parallel where possible), and the subgraph node's resolved inputs are injected into the inner workflow's free input ports (those with no internal connection or config), keyed by port name — so a parent workflow can feed data into a nested one. Explicit inner config and internal wiring always take precedence. A subgraph node's outputs are the inner workflow's free output ports (those nothing inside consumes).
In the editor, select two or more nodes and press Cmd/Ctrl + Shift + G to collapse them into a subgraph node; its boundary ports are derived automatically from the inner workflow. Double-click a subgraph node (or use Enter → in the inspector) to drill into it, and Expand ⤢ in the inspector to inline it again.
The :ai module turns a natural-language prompt into a WorkflowDefinition. WorkflowGenerator has two implementations:
OllamaWorkflowGenerator(OllamaConfig(baseUrl, model))— calls an Ollama host's/api/generatewithformat=json. The prompt embeds the node catalog with port types (type — [in:type] -> [out:type]) so the model only emits real node types and fills each node'sconfigwith type-matched literals for every unconnected input;WorkflowJsonParservalidates the output, coerces config values to each port'sWorkflowType, and drops unknown node types / dangling connections rather than failing. Default modelqwen2.5-coder:14b.PlaceholderWorkflowGenerator— offline canned output for UI development and no-host scenarios.
The editor surfaces this as a docked ✨ AI panel (toggle from the toolbar). Describe a workflow and it generates onto the current canvas, auto-laid-out. The panel keeps a chat transcript so you can iterate, and each result reports what was created plus any unsupported nodes or dropped connections that were sanitized away — so you learn why the graph differs from your request. Generation failures stay inline and retryable.
Every editor action (undo, copy, group, collapse, …) is a rebindable EditorShortcutAction. Open ⌨ Keys in the toolbar, click an action's chord chip, and press the new combination — bindings persist via the settings store and conflicts are rejected. Contextual keys (Escape, Delete) are fixed. See keyboard-shortcuts.md.
| Platform | Status |
|---|---|
| Android | ✅ |
| Desktop (JVM) | ✅ |
| Web (Wasm) | ✅ |
| Web (JS) | ✅ |
| iOS | ✅ XCFramework (SPM) |
| Server (JVM) | ✅ runtime only |
./gradlew :app:desktopApp:run # Desktop
./gradlew :app:androidApp:assembleDebug # Android
./gradlew :app:webApp:wasmJsBrowserDevelopmentRun # Web (Wasm)
./gradlew :app:webApp:jsBrowserDevelopmentRun # Web (JS)Start the Ktor server:
./gradlew :server:runThe server exposes:
| Endpoint | Description |
|---|---|
GET / |
Health check |
GET /nodes |
List all registered node specs |
GET /nodes/{type} |
Get a single node spec by type (404 if unknown) |
POST /validate |
Validate a workflow — returns [ValidationError] |
POST /execute |
Run synchronously — returns WorkflowExecutionResult |
POST /executions |
Start async run — returns { runId } (202) |
GET /executions/{id}/events |
SSE stream of per-node events + final result |
GET /workflows |
List persisted workflows (newest first) |
GET /workflows/{id} |
Load a workflow by ID |
POST /workflows |
Save / upsert a workflow (returns WorkflowMeta, 201) |
DELETE /workflows/{id} |
Delete workflow and its version history |
Set GRAPHYN_API_KEY=<secret> in the environment to enable Bearer-token auth. All endpoints except GET / require the token when the env var is present.
Artifacts are published to Maven Central automatically when a version tag is pushed (git tag vX.Y.Z && git push origin vX.Y.Z). For local / manual publishing without CI, use the Doppler-backed script:
# Prerequisites: doppler CLI + DOPPLER_TOKEN in .env or .env.local
# Doppler project "maven-central / prd" must have MAVEN_CENTRAL_USERNAME,
# MAVEN_CENTRAL_PASSWORD, GPG_SIGNING_KEY, GPG_SIGNING_PASSWORD
# Publish all modules at version from gradle.properties
./scripts/publish-local.sh
# Explicit version
./scripts/publish-local.sh 0.7.4
# Single module only (fastest for hotfixes)
./scripts/publish-local.sh 0.7.4 serverSee .env.example for all supported environment variables and docs/reference/compatibility-matrix.md for the full artifact list.
# core is a folder of submodules — check each (core:execution holds the integration test)
./gradlew :core:model:check :core:execution:check :core:serialization:check :core:data:check
./gradlew :app:shared:jvmTest # canvas + editor UI tests (Roborazzi)
./gradlew :plugins:io:jvmTest # I/O plugin tests
./gradlew :server:test # server route testsFull documentation at ronjunevaldoz.github.io/graphyn-editor.
Add the package in Xcode → File → Add Package Dependencies:
https://github.com/ronjunevaldoz/graphyn-editor
Or in Package.swift:
.package(url: "https://github.com/ronjunevaldoz/graphyn-editor", from: "0.1.0")Apache 2.0 © Ron June Valdoz