Skip to content

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.

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 .lua file
  • 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

Ports are typed. The runtime validates connections between nodes — only matching types can be wired together.

TypeDescriptionExample Value
TextPlain text / string"Hello world"
NumberNumeric value42, 3.14
BoolBoolean flagtrue, false
ImageFilePath to an image file"/tmp/output.png"
AudioFilePath to an audio file"/tmp/speech.wav"
VideoFilePath to a video file"/tmp/clip.mp4"
ModelFile3DPath to a 3D model"/tmp/model.glb"
UrlHTTP/HTTPS URL"https://example.com/file.png"
JsonArbitrary JSON data{ key = "value" }
AssetRefReference to a Plinken asset"asset:abc123"
BlendShapesFacial blend shape weights{ mouthOpen = 0.8, eyeBlink = 0.2 }
ListArray of any type{ "a", "b", "c" }

A flow node is a Lua file that returns a table with metadata, port definitions, and an execute function.

-- my_node.lua
return {
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
}
FieldTypeRequiredDescription
namestringyesDisplay name in the editor
descriptionstringnoTooltip / help text
inputstableyesArray of input port definitions
outputstableyesArray of output port definitions
executefunctionyes(self, inputs) → outputs — the node’s logic
previewfunctionno(self, inputs, outputs) — optional visual preview
FieldTypeRequiredDescription
namestringyesPort identifier (used as key in inputs/outputs tables)
typestringyesOne of the port types above
descriptionstringnoHuman-readable description
defaultanynoDefault value if no connection is made

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)

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)
end

Flow nodes can make HTTP requests to external APIs. The runtime provides a built-in http module for common patterns.

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 → table
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
}
)

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)
FieldTypeDescription
statusnumberHTTP status code
bodystringResponse body
headerstableResponse headers

-- tts.lua
local 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.lua
local 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.lua
local 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.lua
return {
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
}

These nodes ship with Plinken and are available in every workflow:

NodeInputsOutputsDescription
ttsText, voice, API keyAudioFileText-to-speech via ElevenLabs
wavespeedImageFile, AudioFileVideoFileLip-sync video generation via RunPod
downloadUrlImageFile / AudioFileDownload a file from URL
saveany file type, filenameSave to project assets

CommandDescription
lua-workflow:runExecute a workflow by name
lua-workflow:cancelCancel a running workflow
lua-workflow:listList 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)

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.


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.