code
Execute inline Lua code with access to the workflow context.
Parameters
- sourcestring/function*
- Lua source code string **or inline function** to evaluate
- bytecode_b64string*
- Base64-encoded Lua bytecode for function handler mode
- context_keysarray of stringsdefault Full context
- Literal top-level context keys to expose to Lua. An empty list keeps only
_error_message,_error_step,_error_node_typeand_flow_dir, when present
*Exactly one of source or bytecode_b64 must be provided.
Source mode
When source is provided, one of two forms are supported:
- Lua source string, evaluated as an expression/chunk. The last expression's value becomes the node output.
- Lua function, which is compiled to bytecode and executed with sandboxed context as the script body.
Validation compiles string-valued source without executing it and rejects
invalid syntax. Undefined global reads produce warnings with the step name and
line/column positions relative to the decoded source string. Use
ironflow validate --strict to reject those warnings.
Function handler (bytecode) mode
When bytecode_b64 is provided, the base64-encoded Lua bytecode is decoded and loaded as a function. The function is called with the ctx table as its sole argument, and its return value becomes the node output.
Inline function handlers must not capture locals from the enclosing flow file;
IronFlow rejects such handlers because captured upvalues cannot survive
serialization. Declare constants inside the handler or pass values through
ctx. During validation, reads of undefined globals inside inline functions
produce source-positioned warnings; ironflow validate --strict treats them as
failures.
The same self-contained function may be used by multiple code nodes or step
handlers. Validation reports its source warnings once. Distinct function
definitions must have distinguishable start/end line ranges; move same-range
definitions onto separate lines if validation reports ambiguity. See
reused_callbacks.lua.
Context projection
By default, the entire accumulated context is converted before Lua runs, even
if the code reads only one key. Opt in to context_keys when earlier steps
produce large values that this handler does not need:
flow:step("inspect", nodes.code({
context_keys = {"embed_count", "embed_dimension"},
source = function(ctx)
return { checked_count = ctx.embed_count, dimension = ctx.embed_dimension }
end
})):depends_on("embed")
The shorthand function-handler API supports the same projection through its
chainable builder, including handlers created with flow:step_if:
flow:step("inspect", function(ctx)
return { checked_count = ctx.embed_count }
end):context_keys({"embed_count"}):depends_on("embed")
flow:step("constant", function(ctx)
return { ready = true }
end):context_keys({}):depends_on("inspect")
- Omit
context_keysto preserve the full-context default. Lua{}(JSON[]) exposes no user keys, not the default context. - Four small engine diagnostics pass through regardless of the list:
_error_message,_error_step,_error_node_typeand_flow_dir, so a projectedon_errorhandler keeps its diagnostics and relative paths still resolve. Every other key, including bulky engine payloads such as_error_output(the failing step's output) and invocation overlays (_headers,_webhook,_schedule), is hidden unless listed, so a projection can always exclude a large value. Listing an always-present key explicitly is harmless. Projection is a conversion-budget control, not a secrecy boundary. - Keys are exact, case-sensitive top-level names, not paths or templates.
"a.b"selectsctx["a.b"], notctx.a.b. Missing keys remain absent (nil); duplicate names are selected once. Invalid lists fail loading/validation or direct node execution rather than silently exposing the full context. - A builder's projection belongs only to that step, even when multiple steps
reuse the same
nodes.codedescriptor. - Both the global
ctxand the bytecode function's argument refer to the same isolated snapshot. Projection does not remove data from the workflow context, and Lua mutations do not modify stored input values. Outputs merge normally. - Conversion remains eager for selected values. The object root and all
selected keys share one
IRONFLOW_MAX_CONVERSION_NODESbudget (default100000) and the originalIRONFLOW_MAX_CONVERSION_DEPTHlimit (default64). There is no per-key reset. Selecting a 200,000-number array still fails before the handler runs, even if it would read only its first element or nothing. - Projection does not bypass Lua memory/instruction/time limits or output
conversion limits, change step dependencies, or project a
step_ifguard. It is available on code nodes and function handlers only. Declaringcontext_keyson any other node type, whether through the builder or a descriptor such asnodes.log({ context_keys = {...} })ornodes.foreach({ context_keys = {...} }), is rejected at load time withStep '<name>': context_keys is only supported for code nodes and function handlers, soironflow validatefails instead of silently running with the full context.
See the self-contained
context_projection.lua
example, which retains a 200,000-number native-node output while subsequent
handlers inspect only a scalar or use an empty snapshot.
Sandboxing
The Lua VM starts from an allowlist containing computation-oriented table, string, UTF-8, and math functionality. In particular, the following capabilities are unavailable:
osiodebugpackagerequireloadloadfiledofilecollectgarbagestring.dump
Available globals
ctx-- isolated table snapshot of the workflow context, optionally projected withcontext_keys(JSON values are converted to Lua types)env(key)-- function to read environment variables; returns the value as a string ornilif not setjson_parse(str)-- parse JSON text into a Lua tablejson_stringify(value)-- serialize a Lua value to JSON textjson_array(table)-- mark a dense Lua table, including{}, as a JSON arrayjson_object(table)-- mark a string-keyed Lua table, including{}, as a JSON objectjson_null-- non-nilsentinel that preserves JSON null fields and array entriesbase64_encode(str)-- encode bytes/text to base64base64_decode(str)-- decode base64 to bytes/textlog([level], message...)-- write a Lua log lineuuid4()-- generate a random UUID stringnow_rfc3339()-- current UTC timestamp in RFC3339 formatnow_unix_ms()-- current Unix timestamp in milliseconds
Flow and nodes exist only while the flow definition is loaded and are not
available in the isolated handler VM.
Execution limits
Lua execution runs on Tokio's blocking pool and uses process-wide budgets:
IRONFLOW_LUA_MAX_INSTRUCTIONS— default5000000;0disables.IRONFLOW_LUA_MAX_SECONDS— default10;0disables.IRONFLOW_LUA_MAX_MEMORY_BYTES— default134217728;0disables.IRONFLOW_LUA_HOOK_INTERVAL— default10000.IRONFLOW_LUA_GC_AFTER_EXECUTION— defaulttrue.
When the flow step also uses :timeout(seconds), the Lua instruction hook
checks that total step deadline in addition to these limits. Dropping the node
future (for example, explicit workflow cancellation) signals the same hook, so
an infinite loop is interrupted without occupying a Tokio runtime worker. Hook
checks occur every IRONFLOW_LUA_HOOK_INTERVAL instructions, so cancellation
latency is cooperative rather than instruction-exact.
Return Value Handling
| Return type | Behavior |
|---|---|
| Object table | Each key-value pair is merged into the context output |
| Array table | The array is stored under result |
nil | No output is produced |
| Other | The value is stored under the key result in the context output |
Tables must have one JSON-compatible shape: a dense 1-based array or an object with string keys. Cyclic, over-deep, sparse, mixed-key, non-finite, and unsupported values fail the node with a path-rich error instead of being silently discarded.
Context Output
- When returning an object table: each key becomes a context key
- When returning an array table:
result-- the returned array - When returning a scalar:
result-- the returned value - When returning
nil: no keys are added
Example
Inline source:
local flow = Flow.new("calculate_total")
flow:step("compute", nodes.code({
source = function()
local total = ctx.price * ctx.quantity
return { total = total, currency = ctx.currency or "USD" }
end
}))
flow:step("done", nodes.log({
message = "Total: ${ctx.total} ${ctx.currency}"
})):depends_on("compute")
return flow
Reading an environment variable:
local flow = Flow.new("env_check")
flow:step("check", nodes.code({
source = function()
local api_key = env("API_KEY")
return { has_key = api_key ~= nil }
end
}))
flow:step("done", nodes.log({
message = "API key present: ${ctx.has_key}"
})):depends_on("check")
return flow
Function handler mode with base64-encoded bytecode:
local flow = Flow.new("bytecode_demo")
flow:step("run", nodes.code({
bytecode_b64 = "G0x1YVIAAQQEBAgAGZM..."
}))
return flow
The bytecode function receives ctx as its argument:
-- Original source that was compiled to bytecode:
function(ctx)
return { greeting = "Hello, " .. ctx.name }
end