In Optimizely Opal, a code step runs your own Python, JavaScript, or Bash code as a step in a workflow agent. Use a code step when a workflow needs an exact, repeatable result. Calculations, data transformations, and business rules must behave the same way on every run.
A specialized agent step interprets your intent with a large language model (LLM), so its output varies between runs. A code step executes the logic you wrote and returns the same output for the same input. This gives you precise control over the values that downstream steps depend on.
Prerequisites
Confirm the following before you add a code step:
- You have permission to edit the workflow agent.
- Your logic uses only the standard library of the language you choose. The sandbox does not install third-party packages.
When to use a code step
Choose a code step instead of a specialized agent step in the following situations:
- Transform data between steps – Filter, reshape, or aggregate the output of an upstream step.
- Compute a value – Produce the totals, scores, or formatted strings that a downstream step depends on.
- Apply a business rule – Parse structured data or enforce logic that does not fit a specialized agent.
- Break complex logic into stages – Chain several code steps so each one stays small and testable.
A code step has no access to an LLM. For generated text, analysis, or natural language processing, return the data from the code step. Pass that data to a downstream specialized agent step.
How a code step runs
You declare the input variables when you author the step. Everything after that repeats on every execution:
- The orchestrator resolves each declared variable into a single
inputdictionary. - The orchestrator passes
inputand a context (ctx) dictionary to your handler function. - The sandbox runs your handler in an isolated container with no network access.
- The handler returns a value to the orchestrator.
- The orchestrator stores that value as the step output for every downstream step.
Supported languages
Each language uses a different handler signature and offers different support for structured return values.
| Language | Handler signature | Structured return value |
|---|---|---|
| Python | def handler(input, ctx) |
Supported |
| JavaScript | async function handler(input, ctx) |
Supported |
| Bash | Script body with no function signature | Not supported. The step always returns null. |
Bash suits quick file-processing tasks where the deliverable is a file written to $OPAL_OUTPUT_DIR. Choose Python or JavaScript when a downstream step consumes the result as a dictionary, list, or other structured value.
Add a code step to a workflow
Complete the following steps to add a code step to a saved workflow agent.
- Open the workflow agent you want to edit.
- Drag Code from the Logic section into the workspace.
- Click the upstream step.
- Click a connector circle on that step.
- Drag the connector to the code step.
- Enter a name for the step in the Name field.
- (Optional) Enter a Description that explains what the step does.
- Configure the step in the configuration panel. The sections are Language, Input variables, Sample Data, and Code.
- Click Update to save your workflow agent.
The step name and description display on the workflow canvas and in the execution logs.
Language
Select the language for your handler in the Language drop-down list. Python is the default.
Changing the language replaces the editor contents with the starter template for the language you selected. This happens only while the editor still holds an unmodified template. After you edit the code, a language change preserves what you wrote.
Input variables
Input variables define the data your handler receives. Each variable becomes one key in the input dictionary.
To declare a variable, click Add variable and complete the following fields:
- Type – The data type of the variable: String, Number, Boolean, Object, or Array.
-
Mode – The source of the value at run time. Select one of the following options:
- Fixed - Use the same value every time
- Automatic - Determined at runtime
- Accept multiple values – The toggle that resolves the variable to a list of values. When you turn it on, the Minimum items and Maximum items fields display.
-
Name – The key name in the
inputdictionary. The name must start with a letter or an underscore and contain only letters, digits, and underscores. - Description – The purpose of the variable. For Automatic variables, this description guides the value prediction at run time.
- Required – The checkbox that requires the variable to have a value at execution time.
- Value – The fixed value passed to the handler. This field displays only in Fixed mode.
Click Save variable to add the variable to the step. The variable list displays a Fixed or Automatic badge for each entry.
Every variable needs a Description, including Fixed variables. Save variable stays disabled until you enter one.
The mode you select determines where the value comes from at run time:
- Fixed – Uses the value you enter in the Value field. The orchestrator passes it to the handler exactly as you entered it.
- Automatic – Resolves at run time. The orchestrator examines the outputs of upstream steps and uses your description to predict the value. A specific, concrete description improves prediction accuracy.
input dictionary contains one key per declared variable and nothing else. A step with no declared variables receives an empty dictionary. The output of the preceding step never reaches your handler on its own. Declare an Automatic variable for every upstream value your code reads.The orchestrator drops any resolved value that matches no declared variable. When a required Automatic variable resolves to no value, the step fails with a validation error.
Sample data
The Sample Data editor holds a JavaScript Object Notation (JSON) object that stands in for the input dictionary during a test run. The keys in the object match the variable names you declared.
A step with an orders Array variable and a threshold Number variable takes sample data like the following:
{
"orders": [
{ "id": 1, "amount": 50 },
{ "id": 2, "amount": 120 }
],
"threshold": 100
}The editor opens with an empty object, {}. When you declare Fixed variables, the editor seeds their values for you. Enter representative test values for Automatic variables yourself.
Click Format to reindent the JSON. Click Clear to empty the editor.
The sample data must be valid JSON. An invalid JSON error displays next to the editor. Test Run stays disabled until you correct the syntax.
A test run passes your sample data to the handler unchanged. A real execution does not. The orchestrator drops any resolved value that matches no declared variable. Code that reads an undeclared key passes a test run and then receives nothing in production. Declare every key your handler reads.
Code
Write your handler function in the code editor. The editor highlights syntax for the language you selected. A step that has no code yet loads a starter template.
The Python starter template looks like the following:
def handler(input: dict, ctx: dict) -> dict:
# input["variable_name"]: value of each declared variable ({} if none declared)
# ctx["output_dir"]: write output files here (/workspace/output)
# ctx["input_dir"]: read input files here (/workspace/input)
#
# Return this step's output.
return {}The JavaScript starter template looks like the following:
/**
* @param {Object} input
* @param {Object} ctx
* @returns {Object}
*/
async function handler(input, ctx) {
// input.variable_name: value of each declared variable ({} if none declared)
// ctx.output_dir: write output files here (/workspace/output)
// ctx.input_dir: read input files here (/workspace/input)
//
// Return this step's output.
return {};
}The Bash starter template looks like the following:
#!/usr/bin/env bash
# Parse input variables: jq -r '.variable_name' <<< "$INPUT_JSON"
# Write output files to: $OPAL_OUTPUT_DIR
# Read input files from: $OPAL_INPUT_DIR
#
# Note: Bash steps always return null — structured return values are not
# yet supported for Bash. stdout / stderr are captured and shown in the
# execution log.
echo "done"Write the handler function
Your handler receives two arguments: the resolved input data and an execution context.
The input dictionary
The input dictionary holds one key per declared variable. A step that declares no variables receives an empty dictionary.
Read a declared variable by its name:
def handler(input, ctx):
orders = input["orders"]
threshold = input["threshold"]
return {"count": len([o for o in orders if o["amount"] > threshold])}Inside a loop, the current loop item reaches your handler the same way every other value does: as a declared variable in Automatic mode.
The ctx dictionary
The context dictionary, ctx, holds exactly two keys. Both are filesystem paths. It carries no step ID, loop position, or other workflow metadata.
-
ctx["output_dir"]– Path to the output directory,/workspace/output. Write files here to make them available to downstream steps. -
ctx["input_dir"]– Path to the read-only input directory,/workspace/input.
Bash scripts read the same two paths from the $OPAL_OUTPUT_DIR and $OPAL_INPUT_DIR shell variables. A Bash script reads its input variables from $INPUT_JSON, which holds the input dictionary as a JSON string. Opal assigns all three before your script runs and creates the output directory for you.
Opal runs Bash scripts with set -u. Referencing an unset variable fails the step rather than expanding to an empty string. A misspelled variable name exits with code 1 and reports unbound variable.
Opal assigns these as shell variables rather than exporting them, so a child process does not inherit them. Pass a path as an argument when you call another program:
# The child process does not see $OPAL_OUTPUT_DIR, so pass the path in.
python3 my_script.py "$OPAL_OUTPUT_DIR/report.csv"Read input files
Files uploaded to the conversation reach your handler through the input directory. Opal downloads them before the step runs and places them in an attachments subdirectory:
/workspace/input/attachments/<filename>List what arrived, then read what you need:
import os
def handler(input, ctx):
attachments = os.path.join(ctx["input_dir"], "attachments")
available = sorted(os.listdir(attachments)) if os.path.isdir(attachments) else []
filepath = os.path.join(attachments, "data.csv")
with open(filepath, "r") as f:
content = f.read()
return {"files": available, "characters": len(content)}JavaScript reads the same path with fs.readFileSync and ctx.input_dir. Bash reads it from $OPAL_INPUT_DIR.
Write files to the output directory rather than the input directory. A downstream step reads an output file by the path your handler returns.
Return values
The value your handler returns becomes the step output and flows to downstream steps.
- Python and JavaScript handlers return a dictionary, list, string, number, boolean, or
null. The value must contain only JSON types. A handler that returns anything else fails with a serialization error on standard error. - Bash scripts always return
null. Use standard output for logging and write files to$OPAL_OUTPUT_DIRfor downstream consumption.
A handler that returns a dictionary with a summary key gets special treatment. Opal displays that value as a one-line summary in the execution log. Opal also removes the summary key from the return value, so downstream steps do not receive it.
return {
"results": processed_data,
"summary": f"Processed {len(processed_data)} records"
}Call Opal tools from your code
A code step calls any Opal tool through the opal_tms_sdk package, which is pre-installed in the sandbox. This combines the tool catalog with your own logic: call a tool, reshape its output, and return a structured result for the next step.
Calling tools from code also makes the sequence deterministic. Your handler decides which tools run and in what order, rather than leaving an agent to choose.
Tool calling works in Python only. Handlers written in JavaScript or Bash have no equivalent package.
Call a tool by name
Import tools and call any registered tool as a method. The tool name and its parameters match the Opal tool catalog.
from opal_tms_sdk import tools
def handler(input, ctx):
return tools.get_today()When the tool name is known only at run time, pass it to tools.call instead:
from opal_tms_sdk import tools
def handler(input, ctx):
return tools.call(input["tool_name"], **input["tool_params"])The return shape depends on the tool. Most tools return a dictionary with a response_type and a response key.
Tool authentication
Tool calls inherit the identity of the person who runs the workflow agent. Opal creates a scoped session for the step and attaches the credentials when it forwards the call. Your code never handles a token.
A tool that needs a connected account fails when the person running the workflow has not connected it. Google and Microsoft connections work this way. Expired authentication fails the same way. Both surface as ToolUnavailableError.
Reach web content through tools
The sandbox has no network access, so your handler cannot make HTTP requests. Tools run outside the sandbox, so they are the route to anything external. The following tools are available:
- search_web – Search the web and return results.
- browse_web – Read a URL and return its content.
Both are read-oriented. No tool sends an arbitrary POST, PUT, or DELETE request to an external URL.
Handle tool errors
The SDK raises a typed exception for each failure mode, so your handler can return a useful result instead of failing the step.
-
ToolUnavailableError– The session expired, the tool is outside the grant, or the user authentication is missing. -
ToolInvocationError– The tool service returned an error. Readstatusfor the HTTP status code andbodyfor the response. | -
ToolValidationError– The SDK rejected the call before sending it, such as a missing required parameter. -
ProxyChannelError– A network or transport error reached the tools proxy.
Each one subclasses OpalTmsSdkError, so catch that to handle every failure at once.
from opal_tms_sdk import tools, ToolUnavailableError, ToolInvocationError
def handler(input, ctx):
try:
results = tools.search_web(query=input["query"])
except ToolUnavailableError:
return {"error": "Search tool not available", "results": []}
except ToolInvocationError as e:
return {"error": f"Search failed with status {e.status}", "results": []}
return {"results": results, "summary": "Search complete"}Test a code step
A test run executes your handler against the sample data without running the rest of the workflow. Complete the following steps to test your code.
- Click Update to save the workflow agent.
- Enter a JSON object in the Sample Data editor that represents the input your handler expects.
- Click Test Run.
Save the workflow before running this step.The results panel reports the following:
- Status – The outcome of the run: Success, Error (exit N), or Timed out.
- Duration – The execution time in seconds.
-
Summary – The
summaryvalue your handler returned, when it returned one. - stdout – The output your code wrote to standard output.
- stderr – The output your code wrote to standard error. A red dot displays on the tab when this output is not empty.
- return value – The value your handler returned, as formatted JSON.
Review execution results
A code step records its output in the workflow agent's log. Open the log, then click the code step to open its details panel. For the two routes to a log, see Workflow agent logs.
The details panel in the log reports the following:
-
Exit code – The code the sandbox returned, in green for
exit 0and red for any non-zero code. - Duration – The execution time in seconds.
- Input Parameters – The resolved value of each declared variable, with a Fixed or Automatic badge.
-
Summary – The
summaryvalue your handler returned, when it returned one. - stdout and stderr – The captured output of the run.
- Return Value – The value your handler returned, as formatted JSON.
- Output Files – The path and size of each file the step wrote to the output directory.
The Input Parameters section lists every value the orchestrator resolved, including values it then dropped. A value that matches no declared variable displays here but never reaches your handler. Compare it against the variables you declared when input arrives emptier than you expect.
Opal truncates standard output for display at 50,000 characters and standard error at 2,000 characters. Your handler still runs to completion when its output exceeds either limit.
Files your handler writes to the output directory do not display in the details panel. Return their paths so a downstream step reads them.
Error states
The details panel reports four outcomes.
A code step that exits with a non-zero code fails that attempt. Opal then retries the step up to three times before the workflow fails. The execution log lists each attempt separately, so a failing step displays four entries rather than one.
- Exit code 0 – The exit code displays in green. The return value and standard output display normally.
- Exit code 124 – An Execution timed out banner displays. Partial output from standard output and standard error is often available.
- Any other non-zero exit code – A Code exited with error banner displays. Open the stderr tab for the traceback.
- No exit code with an error message – An Execution infrastructure failure banner displays. This reports a platform issue rather than a fault in your code.
Opal wraps your handler in a harness before it runs. An error reports the line number in the assembled script, not the line in your editor. The reported number is always higher, and the difference stays constant for a given language.
Use a code step in a loop
Code steps work inside the Loop logic element, including nested loops. A loop iterates over a collection and runs the steps inside it once for each item.
A code step cannot be the first step inside a loop. Start the loop with an agent step, then place the code step after it.
The loop collects the return value of each iteration. When every iteration finishes, downstream steps receive the collected results as an array.
Sandbox limits
Each code step runs in an isolated container that enforces the following limits.
-
Execution time – 5 minutes. A handler that runs longer exits with
Exit code 124. -
Input size – 74 KB for the resolved
inputdictionary. The step fails before it runs when the input exceeds this size. -
Return value size – 256 KB. Opal replaces a larger return value with
nulland records an error message. The step still succeeds. - Source code size – 100,000 characters.
- Memory – 1 GB.
- Processors – 1 virtual CPU.
- Workspace storage – 5 GB of ephemeral storage.
- Network access – None.
The sandbox runs your code as a non-root user and discards the container when the step finishes.
Known limitations
Code steps do not support the following:
- LLM calls – A code step cannot invoke a language model or any Opal AI capability. Return your data and pass it to a downstream specialized agent step.
- Network access – The sandbox cannot make HTTP requests, call external APIs, or download packages.
- Skill invocation – A code step cannot invoke an Opal skill. Use a specialized agent step instead.
- Third-party packages – The sandbox installs nothing. Use only the standard library of the language you selected.
-
Structured return values in Bash – A Bash step always returns
null. - Live output streaming – Standard output and standard error return after execution finishes.
- Configurable step timeouts – Every code step gets the five-minute sandbox timeout. No per-step setting changes it.
Examples
For handler code to copy and adapt, go to Workflow agent code step examples.
Article is closed for comments.