Workflows
Workflows are DAG (directed acyclic graph) pipelines where each node is a .lua file. Nodes define typed input/output ports, process data, and pass results downstream. Use workflows for media generation, AI processing, batch exports, and any multi-step pipeline.
Open the Workflow Editor from the Create menu, or run a workflow with the lua-workflow:run command.
Architecture
Section titled “Architecture”Each workflow is a graph of flow nodes. Data flows from outputs to inputs through typed connections:
[Text Prompt] → [TTS Node] → [Audio File] → [Save Node] ↓ [WaveSpeed] → [Video File] → [Save Node]- Each node is a standalone
.luafile - Nodes declare their ports (inputs and outputs with types)
- The runtime resolves the DAG and executes nodes in dependency order
- Nodes can call external APIs via HTTP helpers
Port Types
Section titled “Port Types”Ports are typed. The runtime validates connections between nodes — only matching types can be wired together.
| Type | Description | Example Value |
|---|---|---|
Text | Plain text / string | "Hello world" |
Number | Numeric value | 42, 3.14 |
Bool | Boolean flag | true, false |
ImageFile | Path to an image file | "/tmp/output.png" |
AudioFile | Path to an audio file | "/tmp/speech.wav" |
VideoFile | Path to a video file | "/tmp/clip.mp4" |
ModelFile3D | Path to a 3D model | "/tmp/model.glb" |
Url | HTTP/HTTPS URL | "https://example.com/file.png" |
Json | Arbitrary JSON data | { key = "value" } |
AssetRef | Reference to a Plinken asset | "asset:abc123" |
BlendShapes | Facial blend shape weights | { mouthOpen = 0.8, eyeBlink = 0.2 } |
List | Array of any type | { "a", "b", "c" } |
Writing a Flow Node
Section titled “Writing a Flow Node”A flow node is a Lua file that returns a table with metadata, port definitions, and an execute function.
Minimal Node
Section titled “Minimal Node”-- my_node.luareturn { name = "My Node", description = "Does something useful",
inputs = { { name = "text", type = "Text", description = "Input text" } },
outputs = { { name = "result", type = "Text", description = "Processed text" } },
execute = function(self, inputs) local upper = string.upper(inputs.text) return { result = upper } end}Node Table
Section titled “Node Table”| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name in the editor |
description | string | no | Tooltip / help text |
inputs | table | yes | Array of input port definitions |
outputs | table | yes | Array of output port definitions |
execute | function | yes | (self, inputs) → outputs — the node’s logic |
preview | function | no | (self, inputs, outputs) — optional visual preview |
Port Definition
Section titled “Port Definition”| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Port identifier (used as key in inputs/outputs tables) |
type | string | yes | One of the port types above |
description | string | no | Human-readable description |
default | any | no | Default value if no connection is made |
execute(self, inputs)
Section titled “execute(self, inputs)”The execute function is called when the node runs. It receives a table of input values (keyed by port name) and must return a table of output values.
execute = function(self, inputs) -- inputs.text, inputs.count, etc. are populated from upstream nodes local result = process(inputs.text) return { output = result }end- Blocking — the function can perform I/O, HTTP calls, or shell commands
- Return — a table with keys matching your output port names
- Errors — throw with
error("message")to abort the node (the pipeline stops)
preview(self, inputs, outputs)
Section titled “preview(self, inputs, outputs)”Optional. Called after execute to provide a visual preview using the Scene API. Useful for 3D model nodes or nodes that produce visual output.
preview = function(self, inputs, outputs) local scene = pl.scene scene:clear() local model = scene:load(outputs.model) local cam = scene:create_camera("Preview", "perspective") cam.fov = math.rad(45) cam.position = { x = 0, y = 1.5, z = 4 } cam:look_at({ x = 0, y = 1, z = 0 }) scene:set_active_camera(cam)endHTTP Helpers
Section titled “HTTP Helpers”Flow nodes can make HTTP requests to external APIs. The runtime provides a built-in http module for common patterns.
http.get(url, headers)
Section titled “http.get(url, headers)”local http = require("http")
local response = http.get("https://api.example.com/status", { ["Authorization"] = "Bearer " .. api_key})-- response.status → 200-- response.body → string-- response.headers → tablehttp.post(url, body, headers)
Section titled “http.post(url, body, headers)”local response = http.post("https://api.elevenlabs.io/v1/text-to-speech/voice_id", '{"text": "Hello world", "model_id": "eleven_monolingual_v1"}', { ["Content-Type"] = "application/json", ["xi-api-key"] = api_key })http.post_json(url, data, headers)
Section titled “http.post_json(url, data, headers)”Convenience wrapper — encodes data as JSON and sets Content-Type automatically.
local response = http.post_json("https://api.runpod.ai/v2/endpoint/run", { input = { prompt = inputs.prompt, num_inference_steps = 4 }}, { ["Authorization"] = "Bearer " .. runpod_key})
local result = json.decode(response.body)HTTP Response Table
Section titled “HTTP Response Table”| Field | Type | Description |
|---|---|---|
status | number | HTTP status code |
body | string | Response body |
headers | table | Response headers |
Example Nodes
Section titled “Example Nodes”Text-to-Speech Node
Section titled “Text-to-Speech Node”-- tts.lualocal http = require("http")
return { name = "Text to Speech", description = "Convert text to speech using ElevenLabs",
inputs = { { name = "text", type = "Text", description = "Text to speak" }, { name = "voice_id", type = "Text", description = "ElevenLabs voice ID" }, { name = "api_key", type = "Text", description = "ElevenLabs API key" } },
outputs = { { name = "audio", type = "AudioFile", description = "Generated speech audio" } },
execute = function(self, inputs) local response = http.post_json( "https://api.elevenlabs.io/v1/text-to-speech/" .. inputs.voice_id, { text = inputs.text, model_id = "eleven_monolingual_v1" }, { ["xi-api-key"] = inputs.api_key } )
if response.status ~= 200 then error("TTS failed: " .. response.status) end
local path = os.tmpname() .. ".mp3" local f = io.open(path, "wb") f:write(response.body) f:close()
return { audio = path } end}Download Node
Section titled “Download Node”-- download.lualocal http = require("http")
return { name = "Download", description = "Download a file from a URL",
inputs = { { name = "url", type = "Url", description = "URL to download" } },
outputs = { { name = "file", type = "ImageFile", description = "Downloaded file" } },
execute = function(self, inputs) local response = http.get(inputs.url) if response.status ~= 200 then error("Download failed: " .. response.status) end
local path = os.tmpname() .. ".png" local f = io.open(path, "wb") f:write(response.body) f:close()
return { file = path } end}WaveSpeed (Video Generation) Node
Section titled “WaveSpeed (Video Generation) Node”-- wavespeed.lualocal http = require("http")local json = require("json")
return { name = "WaveSpeed", description = "Generate video from image + audio using WaveSpeed on RunPod",
inputs = { { name = "image", type = "ImageFile", description = "Reference image" }, { name = "audio", type = "AudioFile", description = "Driving audio" }, { name = "api_key", type = "Text", description = "RunPod API key" } },
outputs = { { name = "video", type = "VideoFile", description = "Generated video" } },
execute = function(self, inputs) -- Submit job local submit = http.post_json( "https://api.runpod.ai/v2/wavespeed/run", { input = { image = inputs.image, audio = inputs.audio } }, { ["Authorization"] = "Bearer " .. inputs.api_key } ) local job = json.decode(submit.body)
-- Poll for completion while true do pl.sleep(2000) local status = http.get( "https://api.runpod.ai/v2/wavespeed/status/" .. job.id, { ["Authorization"] = "Bearer " .. inputs.api_key } ) local result = json.decode(status.body) if result.status == "COMPLETED" then return { video = result.output.video_url } elseif result.status == "FAILED" then error("WaveSpeed failed: " .. (result.error or "unknown")) end end end}Save Node
Section titled “Save Node”-- save.luareturn { name = "Save to Project", description = "Save a file to the current project's assets",
inputs = { { name = "file", type = "VideoFile", description = "File to save" }, { name = "filename", type = "Text", description = "Target filename", default = "output.mp4" } },
outputs = {},
execute = function(self, inputs) pl.cmd("asset:import", { source = inputs.file, name = inputs.filename }) pl.log("Saved " .. inputs.filename .. " to project") return {} end}Built-in Node Types
Section titled “Built-in Node Types”These nodes ship with Plinken and are available in every workflow:
| Node | Inputs | Outputs | Description |
|---|---|---|---|
tts | Text, voice, API key | AudioFile | Text-to-speech via ElevenLabs |
wavespeed | ImageFile, AudioFile | VideoFile | Lip-sync video generation via RunPod |
download | Url | ImageFile / AudioFile | Download a file from URL |
save | any file type, filename | — | Save to project assets |
Running Workflows
Section titled “Running Workflows”| Command | Description |
|---|---|
lua-workflow:run | Execute a workflow by name |
lua-workflow:cancel | Cancel a running workflow |
lua-workflow:list | List all available workflows |
Workflows can also be started from:
- The Workflow Editor panel
- The Create > Generate menu (G)
- Plinky (describe what you want, and the AI builds the pipeline)
Visual Node Editor
Section titled “Visual Node Editor”Coming soon (TAL-103 Phase 2): A visual drag-and-drop node editor for building workflows graphically. Wire nodes together, configure ports, and see results in real time. Until then, workflows are defined as Lua connection tables or built by Plinky.
Full Example: Text-to-Video Pipeline
Section titled “Full Example: Text-to-Video Pipeline”A complete workflow that takes a text prompt, generates speech, downloads a reference image, creates a lip-sync video, and saves it to the project:
-- Pipeline: text → speech → video → save-- This runs as a workflow DAG, not a single script.-- Each node below would be a separate .lua file wired together in the editor.
-- Node 1: TTS-- inputs: text = "Welcome to Plinken"-- outputs: audio → feeds into WaveSpeed
-- Node 2: Download reference image-- inputs: url = "https://example.com/avatar.png"-- outputs: file → feeds into WaveSpeed
-- Node 3: WaveSpeed-- inputs: image (from Download), audio (from TTS)-- outputs: video → feeds into Save
-- Node 4: Save-- inputs: file (from WaveSpeed), filename = "welcome.mp4"-- outputs: (saved to project)See also: Actions for immediate scripts, Scene API for 3D preview nodes.