Markov ships with fourteen primitive step types. Every step in a workflow must resolve to one of these primitives, either directly or through a custom step type.
All step types support these common fields:
| Field | Type | Description |
|---|---|---|
name |
string, required | Unique step name within the workflow |
type |
string | The step type (primitive or custom) |
when |
string | Pongo2 boolean expression; step is skipped if false |
register |
string | Store the step output in the workflow context under this key |
timeout |
int | Maximum execution time in seconds |
for_each |
string | Context path to a list; runs the step once per item |
for_each_key |
string | Field on each item to use as iteration key (must be unique) |
for_each_sort |
string | Field on each item to sort by before iterating |
as |
string | Variable name for the current item (required when for_each is set) |
concurrency |
int | Max parallel iterations for for_each (defaults to global forks) |
workflow |
string | Name of a sub-workflow to invoke instead of running a type |
Runs a shell command via bash -c. The command executes on the Markov runner host, not inside Kubernetes.
| Param | Type | Required | Description |
|---|---|---|---|
command |
string | yes | Shell command to execute |
| Variable | Type | Description |
|---|---|---|
stdout |
string | Standard output from the command |
stderr |
string | Standard error from the command |
exit_code |
int | Process exit code |
command param is empty or missing.Simple echo:
- name: greet
type: shell_exec
params:
command: "echo 'hello from markov'"
Piped command with registered output:
- name: count_pods
type: shell_exec
params:
command: "kubectl get pods -n default --no-headers | wc -l"
register: pod_count
- name: report
type: shell_exec
params:
command: "echo 'Found {{ pod_count.stdout | trim }} pods'"
Runs a deterministic script on the Markov runner host through an explicit interpreter. Unlike shell_exec, Markov does not build a bash -c command: the interpreter, script arguments, and environment variables are passed directly to the process.
Provide exactly one script source:
content writes an inline script body to a temporary file for this execution.path reads a script from the workflow’s scripts/ directory. The value must be relative to that directory; paths that escape it are rejected. For a directory workflow, use <workflow-root>/scripts/; for a single-file workflow, use a sibling scripts/ directory.script_exec runs on the runner host. To run a script in a container, package it in the image (or mount it) and use k8s_job.
| Param | Type | Required | Description |
|---|---|---|---|
interpreter |
string | yes | Interpreter executable, such as python3, bash, or sh. |
content |
string | one of content/path |
Inline script body. |
path |
string | one of content/path |
Script path relative to the workflow scripts/ directory. |
args |
list | no | Arguments passed to the script after its path. Strings, numbers and booleans; numbers and booleans are passed as their text (3, true). |
env |
map | no | Environment variables added to or overriding the runner environment. Values as for args. |
| Variable | Type | Description |
|---|---|---|
stdout |
string | Standard output from the script. |
stderr |
string | Standard error from the script. |
exit_code |
int | Process exit code. |
An inline Python script:
- name: print-issue
type: script_exec
params:
interpreter: python3
content: |
import os
import sys
print(f"{os.environ['ISSUE']}: {sys.argv[1]}")
args: ["review"]
env:
ISSUE: "RFE-123"
A script stored alongside a directory workflow:
my-workflow/
scripts/
reconcile.py
workflows/
main.yaml
- name: reconcile
type: script_exec
params:
interpreter: python3
path: reconcile.py
args: ["--dry-run"]
Runs the Claude Code CLI non-interactively (claude -p) with --output-format stream-json --verbose and processes its event stream live. Use it to run a prompt or a skill (a slash command) in an execution directory, with limits that Markov enforces while the run is in progress.
The step runs on the Markov runner host and needs the claude CLI installed there.
Authentication. The step never requires or sets ANTHROPIC_API_KEY. It starts claude with the runner’s environment, so an existing OAuth login under HOME (~/.claude) is used. To use a key or token instead, pass it through env. In Kubernetes there is no interactive login in the pod; provide credentials through env or by mounting them.
The prompt is written to the process’s stdin, so it does not appear in the process list.
| Param | Type | Required | Description |
|---|---|---|---|
prompt |
string | one of prompt/skill |
The prompt text. May itself be a slash command such as /review-pr 123. |
skill |
string | one of prompt/skill |
Skill or command name; the prompt becomes /<skill> <args>. |
args |
string | no | Arguments appended after skill. |
chdir |
string | no | Directory to run in. Must exist. Defaults to the Markov process working directory. |
model |
string | no | --model. |
effort |
string | no | --effort. |
fallback_model |
string | no | --fallback-model. |
permission_mode |
string | no | acceptEdits, auto, bypassPermissions (alias bypass), manual, dontAsk, or plan. Unset uses the CLI default, which in non-interactive mode denies anything that would prompt. bypassPermissions lets the run use every tool without asking; only enable it where that is acceptable. |
allowed_tools / disallowed_tools |
string[] | no | --allowedTools / --disallowedTools. Prefer these over bypass. |
append_system_prompt |
string | no | --append-system-prompt. |
add_dirs |
string[] | no | Extra directories the run may access (--add-dir). |
mcp_config |
string or string[] | no | --mcp-config file(s) or JSON. |
settings |
string | no | --settings file or JSON. |
resume / session_id |
string | no | --resume / --session-id. |
bare |
bool | no | --bare: skip hooks, plugins, and CLAUDE.md discovery. Bare mode requires API-key authentication, so it will not use an OAuth login. |
limits |
map | no | Limits enforced by Markov; see below. |
events_limit |
int | no | Maximum events kept in the events output. Default 200; 0 keeps none. Live callbacks are not capped. |
env |
map[string]string | no | Extra environment variables. |
extra_args |
string[] | no | Extra arguments appended verbatim. |
binary |
string | no | Executable to run instead of claude. |
| Limit | Meaning |
|---|---|
max_turns |
Maximum assistant turns (top-level assistant messages). |
max_tokens |
Maximum new tokens: input + output + cache-creation. Cache reads are excluded because every turn re-reads the cached context. |
max_duration |
Maximum wall-clock seconds for the run. |
max_budget_usd |
Passed to --max-budget-usd. On a subscription this is a notional figure rather than a charge. |
Markov counts turns and tokens from the stream as events arrive. When a limit is exceeded it stops the process (and any tools it started), emits a limit_exceeded progress event, and fails the step with claude: limit exceeded: <limit>. The step output is still returned, with limit_exceeded set. Use the step-level timeout as an additional bound.
| Variable | Type | Description |
|---|---|---|
result |
string | Final result text. |
is_error |
bool | True if the run reported an error or a limit was exceeded. |
session_id |
string | Claude session ID. |
model |
string | Model reported by the session. |
num_turns |
int | Turns taken. |
tokens |
int | New tokens counted against max_tokens. |
usage |
map | input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens. |
total_cost_usd |
float | Cost reported by the CLI. |
limit_exceeded |
string | Name of the exceeded limit, or empty. |
stop_reason, terminal_reason, duration_ms, permission_denials |
Passed through from the CLI result when present. | |
events |
list | Compact events (kind plus fields), capped by events_limit. Long strings are truncated. |
events_truncated |
bool | True if events were dropped by the cap. |
exit_code, stderr |
Process exit code and standard error. |
Each stream event is also sent to configured callbacks as a step_progress event while the step runs.
The step fails if a limit is exceeded, the CLI reports is_error, no result event is produced, or the process exits non-zero. Permission denials in bypass-free modes do not fail the step by themselves; inspect permission_denials.
On resume, a completed claude step is skipped like any other step; an interrupted one starts over rather than resuming its session. Concurrent for_each iterations share one login and its rate limits, so use a low concurrency.
- name: review
type: claude
register: review
params:
skill: review-pr
args: "{{ pr }}"
chdir: ./repo
allowed_tools: [Read, Grep, Bash]
limits:
max_turns: 30
max_tokens: 500000
max_duration: 900
- name: report
type: shell_exec
when: "not review.is_error"
params:
command: echo "{{ review.result }}"
Runs ansible-playbook on the Markov runner host. The binary is executed directly (no shell), so parameter values cannot inject shell syntax. ansible-playbook must be installed on the runner (or in the job image when run through a custom environment).
| Param | Type | Required | Description |
|---|---|---|---|
playbook |
string or string[] | yes | Playbook path(s), relative to chdir if set. |
chdir |
string | no | Directory to run in. Must exist. Defaults to the Markov process working directory. |
inventory |
string, string[], or map | no | A path or inline host list ("web01,web02,"), a list of those (one -i each), or an inline inventory in Ansible YAML form (all: {hosts: ..., children: ..., vars: ...}), which is written to a temporary file removed after the run. |
limit |
string or string[] | no | --limit; lists are comma-joined. |
tags / skip_tags |
string or string[] | no | --tags / --skip-tags. |
start_at_task |
string | no | --start-at-task. |
extra_vars |
map or string | no | A map is written to a temporary 0600 JSON file and passed as -e @file, keeping values out of the process list. A string is passed as-is (k=v, JSON, or @file). |
extra_vars_files |
string[] | no | Each passed as -e @file. |
check / diff / become |
bool | no | --check / --diff / --become. |
become_user, become_method, remote_user, connection, private_key, vault_password_file, vault_id |
string | no | Corresponding Ansible flags. |
forks, timeout |
int | no | --forks, --timeout (SSH timeout; use the step-level timeout to bound the whole run). |
verbosity |
int 0-6 | no | Becomes -v repeated. |
env |
map[string]string | no | Extra environment variables (e.g. ANSIBLE_HOST_KEY_CHECKING). ANSIBLE_NOCOLOR=1 is set by default. |
extra_args |
string[] | no | Extra arguments appended verbatim, for flags not listed above. |
binary |
string | no | Executable to run instead of ansible-playbook. |
| Variable | Type | Description |
|---|---|---|
stdout |
string | Standard output. |
stderr |
string | Standard error. |
exit_code |
int | Process exit code. |
recap |
map | Parsed PLAY RECAP: {host: {ok, changed, unreachable, failed, skipped, rescued, ignored}}. |
changed |
bool | True if any host reported changed > 0. |
A non-zero exit code (including failed or unreachable hosts) fails the step. Output is still available to rescue/always handling.
- name: deploy
type: ansible_playbook
register: deploy
params:
chdir: ./infra
playbook: site.yml
inventory:
all:
hosts:
web01: {ansible_host: "{{ web_ip }}"}
tags: [deploy]
extra_vars:
version: "{{ version }}"
become: true
timeout: 900
- name: notify
type: shell_exec
when: deploy.changed
params:
command: echo "changed hosts"
Runs an ad-hoc ansible module against a host pattern. Shares the connection and privilege parameters of ansible_playbook.
| Param | Type | Required | Description |
|---|---|---|---|
pattern |
string | yes | Host pattern, e.g. all, web, web01:web02. |
module |
string | no | Module name (-m). Default command. |
module_args |
string or map | no | -a. A map is JSON-encoded. |
poll, background |
int | no | -P, -B for async execution. |
Also accepted, with the same meaning as for ansible_playbook: chdir, inventory, limit, extra_vars, extra_vars_files, check, diff, become, become_user, become_method, remote_user, connection, private_key, vault_password_file, vault_id, forks, timeout, verbosity, env, extra_args, binary.
stdout, stderr, exit_code, and changed (true if any host line reports CHANGED).
- name: ping
type: ansible
params:
pattern: all
inventory: "web01,web02,"
module: ping
connection: ssh
Displays a prompt and reads one line of input from the Markov runner’s interactive terminal. Use it for local, synchronous decisions; it is not suitable for Kubernetes Jobs, CI, or other non-interactive runners. For a durable approval that survives process exit, use a gate with action: pause and resume the run later.
The response is available through register as value.
| Param | Type | Required | Description | |
|---|---|---|---|---|
message |
string | yes | Text displayed before the input prompt. | |
choices |
string[] | no | Allowed responses. Input must match one of these strings exactly unless case_insensitive is true. |
|
default |
string | no | Value selected when the user presses Enter. When choices are set, it must be one of them. | |
case_insensitive |
bool | no | false |
When true, match choices without regard to case and return the configured choice spelling. |
| Variable | Type | Description |
|---|---|---|
value |
string | The entered response, or default when Enter was pressed. |
message is missing.Empty or invalid responses are explained and re-prompted in the terminal; they do not fail the workflow.
- name: release_decision
type: prompt
params:
message: "Approve release?"
choices: ["yes", "no"]
default: "no"
case_insensitive: true
register: decision
- name: release
type: shell_exec
when: "decision.value == 'yes'"
params:
command: "./release.sh"
See the complete interactive example.
Creates a Kubernetes batch/v1 Job and polls for completion every 5 seconds. The pod’s RestartPolicy is always Never.
| Param | Type | Default | Description |
|---|---|---|---|
image |
string, required | – | Container image |
command |
string[] or string | – | Container command (entrypoint) |
args |
string[] or string | – | Container arguments |
env |
map[string]any | – | Environment variables as key-value pairs |
secrets |
string[] | – | Kubernetes Secret names to inject via envFrom / secretRef |
volumes |
list | – | Volume specifications (see below) |
init_containers |
list | – | Init container specifications (see below) |
resources |
map | – | Resource requests/limits using standard Kubernetes quantities |
affinity |
map | – | Pod affinity specification (see below) |
service_account |
string | – | Kubernetes ServiceAccount name for the pod |
backoff_limit |
int | 0 |
Kubernetes Job .spec.backoffLimit |
ttl_seconds |
int | 86400 |
TTL in seconds after Job completion (.spec.ttlSecondsAfterFinished) |
image_pull_policy |
string | IfNotPresent |
Image pull policy (Always, IfNotPresent, Never) |
namespace |
string | – | Override the global namespace for this step |
name_prefix |
string | markov |
Prefix for the auto-generated Job name |
Each entry in the volumes list is a map with:
| Field | Type | Description |
|---|---|---|
name |
string, required | Volume name |
pvc |
string | PersistentVolumeClaim name (mutually exclusive with config_map and secret) |
config_map |
string | ConfigMap name |
secret |
string | Secret name |
mount |
string | Mount path inside the container |
read_only |
bool | Mount as read-only |
If none of pvc, config_map, or secret is specified, the volume is created as an emptyDir.
Each entry in the init_containers list is a map with:
| Field | Type | Description |
|---|---|---|
name |
string, required | Container name |
image |
string, required | Container image |
command |
string[] or string | Container command |
args |
string[] or string | Container arguments |
volume_mounts |
list | Each entry has name (string) and mount_path (string) |
image_pull_policy |
string | Defaults to IfNotPresent |
affinity:
pod_affinity:
required:
topology_key: kubernetes.io/hostname
match_labels:
app: my-app
This maps to a requiredDuringSchedulingIgnoredDuringExecution pod affinity term.
resources:
requests:
memory: 2Gi
cpu: 500m
limits:
memory: 8Gi
cpu: "2000m"
Values are parsed using Kubernetes standard quantity parsing (e.g., 500m, 2Gi, "2000m").
These fields are set automatically by the engine and should not be specified in workflow YAML:
| Field | Description |
|---|---|
_job_name |
Sanitized Kubernetes name, max 63 characters. Format: {name_prefix}-{run_id}-{step_name}. If the raw name exceeds 63 characters, it is truncated and a SHA-256 hash suffix is appended. |
_labels |
Labels applied to the Job and Pod: app=markov, markov/run-id, markov/workflow, markov/step |
| Variable | Type | Description |
|---|---|---|
job_name |
string | Name of the created Kubernetes Job |
namespace |
string | Namespace the Job was created in |
logs |
string | Pod logs, up to 64 KB |
image param is empty or missing.Failed.timeout expires or the parent context is cancelled.Complete k8s_job with volumes and secrets:
- name: run_analysis
type: k8s_job
timeout: 600
params:
image: pipeline-agent:latest
command: ["/bin/bash", "/app/scripts/run_skill.sh"]
args: ["--issue", "{{ issue }}", "--model", "opus"]
namespace: markov-pipelines
service_account: pipeline-runner
backoff_limit: 0
ttl_seconds: 3600
image_pull_policy: Always
env:
ISSUE_KEY: "{{ issue }}"
LOG_LEVEL: debug
secrets:
- pipeline-secrets
- gcp-credentials
volumes:
- name: workspace
pvc: pipeline-workspace-pvc
mount: /app/workspace
- name: config
config_map: pipeline-config
mount: /app/config
read_only: true
- name: creds
secret: api-credentials
mount: /app/secrets
read_only: true
- name: scratch
mount: /tmp/scratch
init_containers:
- name: fetch-source
image: alpine/git:latest
command: ["/bin/sh", "-c"]
args: ["git clone https://github.com/org/repo.git /workspace/src"]
volume_mounts:
- name: workspace
mount_path: /workspace
resources:
requests:
memory: 2Gi
cpu: 500m
limits:
memory: 8Gi
cpu: "2000m"
affinity:
pod_affinity:
required:
topology_key: kubernetes.io/hostname
match_labels:
app: pipeline-dashboard
register: job_result
Waits for an existing Kubernetes batch/v1 Job to complete or fail. Use this when another service creates the Job and Markov only needs to watch it, collect pod logs, and continue the workflow.
This complements k8s_job: k8s_job creates a new Job, while k8s_job_wait watches a named Job that already exists or may appear shortly.
| Param | Type | Default | Description |
|---|---|---|---|
job_name |
string, required | – | Name of the Kubernetes Job to watch |
namespace |
string | workflow namespace | Override the global namespace for this step |
timeout |
int | 3600 |
Seconds to wait for the Job to appear and then finish. Set to 0 to rely only on the step or parent context timeout. |
tail_logs |
bool | true |
Capture pod logs after completion or failure |
log_bytes |
int | 65536 |
Maximum number of log bytes to capture |
| Variable | Type | Description |
|---|---|---|
job_name |
string | Name of the watched Kubernetes Job |
namespace |
string | Namespace the Job was watched in |
status |
string | completed, failed, running, or pending when the wait is cancelled before the Job appears |
logs |
string | Pod logs, up to log_bytes, when tail_logs is true and logs are available |
job_name is empty or missing.Failed.timeout expires or the parent context is cancelled.Submit a Job through an external dashboard API, then wait on the Kubernetes Job directly:
- name: submit
type: http_request
params:
base_url: "http://pipeline-dashboard:5000"
path: /api/jobs/submit
method: POST
body:
fqn: "github.local/opendatahub-io/rfe-creator@main:rfe.speedrun"
args:
issue: RHAIRFE-1
model: opus
register: submitted_job
- name: wait_for_completion
type: k8s_job_wait
params:
job_name: "{{ submitted_job.body.job_name }}"
timeout: 3600
tail_logs: true
register: watched_job
A failed Job fails the step. To record it and carry on (for example, when a failed Job is a
result to report rather than a reason to stop), set ignore_errors; the registered output then
has status: failed, the logs, failed: true and error:
- name: wait_for_run
type: k8s_job_wait
ignore_errors: true
params:
job_name: "{{ submitted_job.body.job_name }}"
register: watched_job
Reusable custom type:
step_types:
agent_job_wait:
base: k8s_job_wait
params:
namespace: ai-pipeline
timeout: 3600
tail_logs: true
log_bytes: 131072
Makes HTTP requests with automatic JSON body encoding and response parsing.
| Param | Type | Default | Description |
|---|---|---|---|
method |
string | GET |
HTTP method (GET, POST, PUT, DELETE, etc.) |
url |
string | – | Full request URL. Required if base_url is not set. |
base_url |
string | – | Base URL, concatenated with path to form the full URL |
path |
string | – | Path appended to base_url |
body |
any | – | Request body, JSON-encoded automatically |
headers |
map[string]string | {} |
Custom HTTP headers to add to the request |
basic_auth |
map | – | HTTP Basic Auth credentials with username and password fields |
ignore_status |
bool or list[int] | – | Treat matching HTTP error status codes as success. true ignores all >= 400 responses. |
tls_insecure |
bool | false |
Skip TLS certificate verification for HTTPS requests. Intended for local development or trusted test environments. |
tls_ca_cert |
string | – | Path to a PEM-encoded CA certificate bundle to trust for this request. Useful for self-signed or private CA certificates. |
When a body is provided, the Content-Type header is set to application/json. A custom Content-Type value in headers overrides that default. If both basic_auth and a custom Authorization header are provided, basic_auth takes precedence.
For HTTPS endpoints with self-signed certificates, prefer tls_ca_cert when you have the CA certificate. Use tls_insecure: true only when certificate verification must be disabled.
Either url or base_url must be specified. When both base_url and path are given, they are concatenated directly (no slash is inserted).
| Variable | Type | Description |
|---|---|---|
status_code |
int | HTTP response status code |
body |
any | Response body. Auto-parsed as JSON if the response is valid JSON; otherwise returned as a string. |
url nor base_url is provided.ignore_status. The step fails, but output variables (including the response body) are still populated.GET request with registered output:
- name: fetch_issues
type: http_request
params:
base_url: "https://issues.redhat.com/rest/api/2"
path: "/search?jql=project=MYPROJECT&maxResults=50"
method: GET
register: issue_list
- name: report_count
type: shell_exec
params:
command: "echo 'Found {{ issue_list.body.total }} issues'"
POST request with JSON body:
- name: create_webhook
type: http_request
params:
url: "https://api.example.com/webhooks"
method: POST
body:
name: "pipeline-notify"
url: "https://hooks.example.com/markov"
events: ["job.completed", "job.failed"]
register: webhook
Authenticated request with custom headers:
- name: create_issue
type: http_request
params:
base_url: "https://issues.example.com/rest/api/2"
path: "/issue"
method: POST
basic_auth:
username: "{{ jira_user }}"
password: "{{ jira_password }}"
headers:
Accept: "application/json"
body:
fields:
project:
key: PIPE
summary: "Pipeline-created issue"
issuetype:
name: Task
register: issue
Token auth and tolerated status codes:
- name: ensure_repo
type: http_request
params:
base_url: "https://github.example.com/api/v3"
path: "/orgs/{{ org }}/repos"
method: POST
headers:
Authorization: "token {{ github_token }}"
Accept: "application/vnd.github+json"
ignore_status: [422]
body:
name: "{{ repo_name }}"
register: repo_create
HTTPS request to an endpoint signed by a private CA:
- name: fetch_internal_status
type: http_request
params:
url: "https://internal.example.test/status"
headers:
Accept: "application/json"
tls_ca_cert: "/etc/markov/certs/internal-ca.pem"
register: internal_status
For disposable local environments, certificate verification can be disabled:
- name: fetch_local_status
type: http_request
params:
url: "https://github.local/api/v3"
tls_insecure: true
register: github_status
Evaluates named rules against the workflow context using the Grule rule engine. Gates control workflow progression by producing an action (continue, skip, or pause) and optionally setting facts in the context.
| Field | Type | Required | Description |
|---|---|---|---|
rules |
string[] | yes | Names of rules to evaluate (defined in the top-level rules: block) |
facts |
map[string]any | no | Additional context values to make available to rule conditions. Template expressions are rendered against the current context. |
rules: block)| Field | Type | Default | Description |
|---|---|---|---|
name |
string, required | – | Rule identifier, referenced by gate steps |
description |
string | – | Human-readable description |
salience |
int | 0 |
Priority (higher salience fires first) |
when |
string, required | – | Condition expression (Pongo2 syntax) |
action |
string | continue |
Action when the rule fires: continue, skip, or pause |
set_fact |
map[string]any | – | Variables to set in the workflow context when the rule fires |
| Variable | Type | Description |
|---|---|---|
action |
string | The winning action: continue, skip, or pause |
fired_rules |
string[] | Names of all rules that fired |
facts |
map[string]any | Values set by set_fact across all fired rules |
rules list are compiled from Pongo2 conditions into GRL (Grule Rule Language) and evaluated together.set_fact values from all fired rules are merged back into the workflow context, making them available to downstream steps.pause persists a gate receipt, marks the step and run as paused, and stops execution. Resume the run with at least one --var key=value override; the paused gate is evaluated again with that input.skip is recorded as the gate action, but does not yet change engine control flow.Rules see global vars, workflow vars, CLI --var overrides, and set_fact values. They do not see step results, register outputs, or artifact data directly. To pass step data to a rule, map it through the gate’s facts block:
- name: quality_gate
type: gate
facts:
score: "{{ analysis.artifacts.result.confidence }}"
rules:
- auto_approve
- needs_review
This keeps rules portable across workflows.
rules: block.rules:
- name: tests_pass
description: "Allow deployment when coverage meets threshold"
salience: 100
when: "test_coverage >= min_coverage"
action: continue
set_fact:
tests_approved: true
- name: tests_fail
description: "Block deployment on insufficient coverage"
salience: 200
when: "test_coverage < min_coverage"
action: skip
set_fact:
tests_approved: false
block_reason: "Coverage below threshold"
workflows:
- name: main
steps:
- name: test_gate
type: gate
rules:
- tests_pass
- tests_fail
- name: deploy
type: shell_exec
when: "tests_approved"
params:
command: "echo 'Deploying...'"
Computes and stores variables in the workflow context. Unlike other step types, set_fact does not use params – it uses the vars field directly on the step.
| Field | Type | Required | Description |
|---|---|---|---|
vars |
map[string]any | yes | Key-value pairs to evaluate and store in the context |
Each value in the vars map is evaluated according to its type:
| Value Type | Behavior |
|---|---|
String that is exactly one {{ expr }} |
Evaluated to the expression’s native value: "{{ tiers[tier] }}" stores the map, "{{ names \| csv }}" the list. A value that comes out as text is coerced as in the next row |
String containing {{ or {% |
Rendered as a Pongo2 template, then coerced: "true" becomes true (bool), integer strings are parsed to int, JSON arrays/objects are parsed into native types |
String with {{ path \| from_json }} or {{ path \| fromjson }} |
The context path is resolved and its string value is parsed as JSON, preserving structure (maps, arrays, nested types) |
| Plain string (no template syntax) | Evaluated as a boolean expression via {% if expr %}true{% endif %} |
Map with a from key |
Table lookup (see below) |
| Any other map, or a list | Rendered like step params: template strings are rendered (exact expressions keep native types) and other strings are kept as they are, so a whole record can be built in one fact |
| Any other type | Stored directly (int, bool) |
When a value is a map containing a from key, it performs a table lookup:
| Field | Type | Description |
|---|---|---|
from |
string, required | Context path to a list of maps |
match |
map[string]any, required | Filter criteria – each key-value pair must match |
field |
string | Extract a single field from the matched row. If omitted, the entire row is returned. |
default |
any | Fallback value if no match is found or the source list is nil |
Match values can contain template expressions (e.g., "{{ issue }}").
set_fact does not produce output in the register sense. The computed values are merged directly into the workflow context and are available to all subsequent steps.
vars map is empty or not defined.from path resolves to a non-list type.match field.Template rendering and arithmetic:
- name: compute_vars
type: set_fact
vars:
stage: "{{ stage + 1 }}"
label: "{{ environment }}-{{ build_version }}"
is_production: "environment == 'production'"
health_status: "healthy"
The stage value rendered from "{{ stage + 1 }}" produces an integer string like "3", which is coerced to the integer 3.
The is_production value is a plain string without template delimiters, so it is evaluated as a boolean expression.
Table lookup:
- name: find_owner
type: set_fact
vars:
owner:
from: team_roster.artifacts.members.rows
match:
component: "{{ component }}"
role: lead
field: email
default: "unassigned@example.com"
JSON parsing with from_json:
- name: parse_result
type: set_fact
vars:
parsed_data: "{{ job_output.stdout | from_json }}"
Loads files from the filesystem or from Kubernetes pods into the workflow context as structured data.
| Field | Type | Required | Description |
|---|---|---|---|
artifacts |
map[string]Artifact | yes | Named artifacts to load |
| Field | Type | Default | Description |
|---|---|---|---|
path |
string, required | – | File path (template expressions are rendered against the context) |
format |
string | – | How to parse the file: yaml, markdown, markdown_table, or omit for raw string |
source |
string | auto | Where to read: local (filesystem), k8s (exec cat in a running pod), or omit for auto-detection |
optional |
bool | false |
If true, missing files produce nil instead of failing |
| Format | Parsed Result |
|---|---|
yaml |
map[string]any – parsed YAML document |
markdown |
map[string]any with two keys: frontmatter (parsed YAML between --- delimiters) and content (body text after the second ---) |
markdown_table |
map[string]any with rows (list of maps with snake_case headers) plus one key per column header containing a comma-joined string of all values in that column |
| (omitted) | string – raw file contents |
Loaded artifacts are placed in the context at {step_name}.artifacts.{artifact_name}. For example, a step named load_config with an artifact named settings is accessible as load_config.artifacts.settings.
When source is omitted:
namespace set, the engine attempts to read from running pods in that namespace using kubectl exec cat.For k8s source, the engine lists running pods in the workflow namespace (up to 20), iterates through containers with a Ready status, and tries cat {path} in each until one succeeds.
artifacts map is empty.optional is false.- name: load_config
type: load_artifact
artifacts:
settings:
path: "/app/config/pipeline.yaml"
format: yaml
readme:
path: "/app/docs/README.md"
format: markdown
optional: true
test_results:
path: "/app/reports/coverage.md"
format: markdown_table
- name: check_config
type: assert
that:
- "load_config.artifacts.settings.log_level == 'debug'"
- "load_config.artifacts.readme != None"
msg: "Configuration validation failed"
Artifacts can also be loaded alongside executor steps. When a step with a type like k8s_job includes an artifacts field, the engine loads those artifacts after the job completes and merges them into the step’s context entry alongside the executor output:
- name: run_analysis
type: k8s_job
params:
image: pipeline-agent:latest
command: ["/bin/sh", "-c"]
args: ["python /app/analyze.py --issue {{ issue }}"]
artifacts:
result:
path: "/app/artifacts/{{ issue }}/result.yaml"
format: yaml
# Access both executor output and artifacts:
# run_analysis.job_name, run_analysis.logs
# run_analysis.artifacts.result.confidence
Validates conditions and fails the workflow immediately if any condition evaluates to false. Use for precondition checks and invariant validation.
| Field | Type | Required | Description |
|---|---|---|---|
that |
string[] | yes | List of Pongo2 boolean expressions to evaluate |
msg |
string | no | Custom failure message. If omitted, the default message is "assertion failed: {expression}". |
Each expression in the that list is evaluated using EvalBool, which wraps the expression in {% if expr %}true{% endif %} and checks whether the result is "true". Expressions are evaluated in order; the step fails on the first false expression.
that list is empty.that fails to parse or evaluate.that evaluates to false.- name: preflight_checks
type: assert
that:
- "build_version is defined"
- "environments | length > 0"
- "test_coverage >= min_coverage"
msg: "Preflight checks failed -- verify build_version, environments, and test_coverage"
- name: verify_triage
type: assert
that:
- "needs_review"
- "not auto_approved"
- "not rejected"
- "not deferred"
msg: "Triage gate did not produce expected results for severity={{ severity }}"
Reserved primitive for future LLM integration. Steps using this type will fail with "no executor for type".
This type is recognized by the parser as a valid primitive, so workflow YAML referencing it will pass validation. However, no executor is registered for it at runtime.
# This will parse successfully but fail at execution time.
- name: classify
type: llm_invoke
params:
model: sonnet
prompt: "Classify this issue..."
register: classification
type: jev calls a configured Jev-compatible API and registers structured answers. See Jev decisions for parameters, response metadata, and errors.