Version: 1.0
Created by: Yuri Bernstein
Last Updated: April 2025
Welcome to Koreflow — a GitOps-native, flexible, and modular workflow automation engine designed for engineers who want full control over operations, approvals, and automation without compromising simplicity or auditability. Koreflow allows you to define everything declaratively using human-readable YAML, with clear structure and safe execution by design.
- Python 3.13
- Create virtual environment and install the requirements
pip install -r requirements.txt
Main configuration file is located in configuration/confi.yaml and has it's own README. Please refer to it before starting the engine
Engine can be run as a binary file using
./run.sh linux or
./run.sh macosThe binaries can be recompiled using the build scripts located in dev_utils directory
The engine can also be started with
python koreflow.pyYou can wrap either one into a docker of your choice. I will add a Dockerfile in the future
Hi, I’m Yuri Bernstein,
Koreflow is a tool born out of neccessity of glueing multiple tools together and a consistent need to automate outdated processes.
Koreflow is directly inspired by a core concept I describe in my book: "Letters from the Trenches: The Tech Manager’s Survival Guide."
The key to automation isn't just writing scripts.
It’s breaking down processes into reusable, universal blocks.
Every repeatable operational flow, no matter how complex, can be expressed as a series of five Operational Blocks:
| Block | Description |
|---|---|
| 🟦 Ask | Request structured input (e.g., form, Slack) |
| 🟩 Act | Perform an action (deploy, restart, etc.) |
| 🟨 Gate | Wait for approval or external decision |
| 🟧 Wait | Pause until a condition or event is met |
| 🟥 State | Report or publish result/status |
These building blocks are at the core of Koreflow.
You don’t need to learn a new language or platform.
You simply declare what to ask, what to do, what to wait for, and what to report.
The system interprets these blocks, enforces structure, manages execution, and integrates with Slack, APIs, approvals, forms, and more — all with Git-native, state-aware workflows.
Koreflow is a Git-backed engine that:
- Listens for incoming events (like GitHub webhooks or API calls),
- Matches them against user-defined conditions,
- Executes multi-step workflows defined in YAML,
- Interacts with external systems via pluggable modules,
- Collects user input via forms or approvals,
- Tracks and persists state across restarts.
It's purpose-built for DevOps, SRE, Support, and internal tooling use cases, but due to it's flexibility it can be used for anything and anywhere.
Every workflow can be triggered using different methods:api | git | scheduled | ad-hoc | aiagent (check docs/agents.md for more details).
The process is:
[ Event Trigger ]
↓
[ Matching Engine ] ← based on your match block
↓
[ Workflow Execution Engine ]
↓
[ Step-by-step Execution in any order ]
• action steps (call module methods)
• approval steps (wait for user)
• webform steps (collect data)
Each workflow is defined in a .yaml file, placed in a Git-managed directory. Workflows contain steps, conditions, variables, and references to reusable modules (which are just Python classes with method definitions).
There are many great tools out there, but here is what Koreflow has to offer
| Feature | Benefit |
|---|---|
| GitOps-native | All workflows, modules, and variables live in Git |
| Modular | Easily extend with Python-based plugins - write your own modules! |
| Declarative | Use clear YAML structures to define your workflows |
| Safe Execution | Step-level tracking with safeguards in place |
| Approval & Input Support | Approvals, Slack, and fully dynamic webforms |
| Durable & Recoverable | Workflows resume from last successful step |
| Term | Description |
|---|---|
| Workflow | A .yaml file that defines conditions and steps |
| Step | A single unit of work in a workflow (e.g., action, approval, form) |
| Module | A Python class with methods you call from your YAML |
| Context | A dynamic dictionary storing variables and results |
| Match | Conditions that decide whether a workflow should run |
| Approval | Human confirmation step (Link or Webform) |
| Form | A configurable UI to collect structured user input |
-
DevOps/SREs automating internal ops processes
-
Engineers building self-service flows (onboarding, provisioning, integration, deployment)
-
Developers looking for audit-friendly workflow engines
-
Platform teams integrating systems with human checkpoints
-
Key components:
- GitOps workflows
- YAML DSL
- Triggers (event, cron)
- Step types:
action,approval,webform
Koreflow workflows are defined in simple YAML files and organized inside wprkflows/<subfolder> directory. Each subfolder may contain one or many workflows and is auto-discovered on startup and at runtime.
Each workflow lives inside the workflows/ folder of a registered repository:
workflows/operations
├── deploy_to_prod.yaml
└── rotate_cert.yaml
Each file defines a full workflow using the Koreflow DSL.
On startup, the engine scans the folder structure and automatically registers the following route:
POST /api/<subfolder>/<workflow_name>
For example:
POST /api/operations/deploy_to_prod
Here’s a basic workflow that reacts to a payload with mode: test and sends a Slack message:
workflow:
name: hello_world
match:
conditions:
- path: payload.mode # parses the incoming payload searching for the key `mode`
operator: equals
value: "test" # checks if they value of the key `mode` matches to `test`
id: test_mode # registes matching condition as `test_mode`
condition_logic: test_mode # if `test_mode` condition is true (e.g. payload.mode is `test`) then the workflow will be executed
payload_parser:
- path: payload.user # parses the incoming payload searching for the key `user`
var: user # registers the value from the payload as variable `user` in the workflow context, allowing to re-use it
absent_action: ignore # if the value is not present - workflow will continue. if not set - workflow will fail
context_variables:
- name: channel
default: "#general"
steps:
- id: say_hello
type: action
action: slack_module.Slack.send_info_message
input:
channel: "{{ context.channel }}"
title: "Hello!"
message: "Triggered by {{ context.user or 'unknown' }}"Use curl to trigger execution:
curl -X POST http://localhost:8080/api/samples/sample \
-H "Content-Type: application/json" \
-d '{"mode": "test", "user": "Yuri"}'If the match conditions pass, the engine runs the workflow step by step and delivers a message to Slack. (you will have to configure slack integration for it to work - more on that later)
- Koreflow discovers your
.yamlfiles and binds them to API endpoints. - It waits for a trigger (e.g. webhook, CLI, or
curl) containing a payload. - It evaluates the
match:block to decide whether to run. - If the match passes, it extracts values into
contextusingpayload_parser. - Then, it runs each
step.
- You can have multiple workflows per repo.
- Route format:
/api/<subfolder>/<workflow_name> - You can nest logic inside steps with conditions and dynamic variable registration.
- All values become part of the live workflow context — accessible with
{{ context.xxx }}
Before a workflow runs, Koreflow first checks whether it should run — using the match: block. This is how you bind workflows to specific events, payloads, or conditions — without writing code.
Think of it as a router + filter — only matching events trigger execution.
match:
conditions:
- path: payload.mode
operator: equals
value: "test"
id: is_test
- path: payload.repo.name
operator: is_in
value: ["infra", "tools"]
id: valid_repo
condition_logic: is_test and valid_repo| Field | Description |
|---|---|
conditions |
A list of evaluations applied to the incoming JSON payload |
path |
Dot-notation path into the payload (supports [*] wildcard for arrays) |
operator |
The logic applied to the extracted value |
value |
The expected value (required for most operators) |
id |
A short name used in condition_logic to refer to this condition |
condition_logic |
Boolean expression combining condition IDs with and, or, not, etc. |
| Operator | Logic |
|---|---|
equals |
actual == value |
not_equals |
actual != value |
present |
Value is not None |
absent |
Value is None or missing |
is_in |
actual in value (value must be a list) |
not_in |
actual not in value |
contains |
value in actual (only for actual that is a string) |
not_contains |
value not in actual (only for actual that is a string) |
starts_with |
actual.startswith(value) (only for strings) |
length |
len(actual) == value (for lists or strings) |
Note: Some operators like
present,absent, andlengthdon’t need avaluefield.
Paths follow dot-notation:
payload.repo.nameYou can also use wildcard array selectors like:
payload.labels[*]Internally, these paths are resolved by the extract_json_path() function and support basic list flattening, but not complex JSONPath features.
The condition_logic field supports:
and,or,not- Parentheses
()for grouping - Securely evaluated using Python’s AST parser
Example:
condition_logic: is_test and (valid_repo or not has_issue)- All expressions are parsed with
astfor safety. - Only a limited subset of logical operations and symbols are allowed.
- Evaluation failures are logged and return
False.
Use debug mode to trace actual values:
[ MATCH ] is_test: path=payload.mode actual="test" → True
[ MATCH ] valid_repo: path=payload.repo.name actual="infra" → True
[ MATCH LOGIC ] is_test and valid_repo → TrueIf no match occurs, the workflow will not run:
{
"status": "ignored",
"reason": "match conditions not met"
}valuemust match types exactly (string vs int matters).- For
is_in/not_in, thevaluemust be a list. - Use
presentandabsentfor defensive conditions.
Once a workflow is matched, it needs data to work with. That’s where context comes in.
The context is a key-value dictionary that stores everything your workflow needs: parsed fields from the payload, predefined variables, outputs from previous steps, form inputs, approval results, and more.
This section explains how to build and use context data using payload_parser and context_variables.
Extract fields from the incoming payload and assign them to named context variables.
payload_parser:
- path: payload.repo.name
var: repo_name
- path: payload.author
var: author
absent_action: ignore| Field | Description |
|---|---|
path |
Dot-notation path to extract value from the payload |
var |
Name to store the result in context |
absent_action |
Optional: fail (default) or ignore |
If the field is missing and absent_action is not set to ignore, the workflow will fail before execution.
Define default or constant variables to be used inside your workflow.
context_variables:
- name: channel
type: string
default: "#ops-alerts"
description: "Default Slack channel for notifications"| Field | Description |
|---|---|
name |
Variable name (becomes context.name) |
type |
Optional type hint (string, int, object, array) |
default |
Default value (used if not overridden by payload) |
description |
Optional field for documentation clarity |
The context is populated in this order:
- Values from
context_variables(defaults), - Values extracted via
payload_parser, - Variables registered during step execution (
register_output,register_vars), - External approval or form inputs (like
form_result), - Any dynamically computed or templated variables.
Incoming JSON:
{
"payload": {
"mode": "test",
"user": "alice"
}
}YAML:
payload_parser:
- path: payload.mode
var: mode
- path: payload.user
var: user
context_variables:
- name: channel
default: "#general"Resulting context:
{
"mode": "test",
"user": "alice",
"channel": "#general"
}Anywhere in the workflow YAML, you can reference values like:
input:
user: "{{ context.user }}"
channel: "{{ context.channel }}"Or even apply logic:
condition: "{{ context.mode }} == 'test'"These values are rendered using Jinja-like templating and evaluated at runtime with full context awareness.
- All context values are strings unless explicitly set or parsed differently.
- You can safely nest values (e.g.,
context.form_result.form_data.start) - Use
absent_action: ignorefor optional fields to avoid breaking execution.
Each workflow is a sequence of steps.
A step defines a single unit of work — like calling a function, waiting for approval, or collecting data from a form.
Steps are declared in a list under workflow.steps, and are executed in the order they appear.
- id: fetch_info
type: action
action: example_module.Example.fetch
input:
param1: "{{ context.user }}"
register_output: fetch_result| Field | Description |
|---|---|
id |
Required. Unique step ID within the workflow. |
type |
action, approval, or webform. |
action |
For action steps: module.class.method to call. |
input |
Dict passed as kwargs to the method. Supports templating. |
conditions |
Optional condition(s) to run this step (see below). |
register_output |
Store method result in context under a named key. |
register_vars |
Compute additional variables after the step. |
Calls a method from a loaded Python module.
- id: notify
type: action
action: slack_module.Slack.send_info_message
input:
channel: "{{ context.channel }}"
title: "Status Update"
message: "User {{ context.user }} just ran this workflow."- The module method receives
**input. - Execution results are stored under
context.step_results.notify.statuscontext.step_results.notify.messageandcontext.step_results.notify.datawherenotifyis your step id. - You can access the step results in other steps.
Generates an approval link and waits for a manual approval (via link hit).
- id: approve_step
type: approval
message: "Do you want to deploy to production?"
timeout_minutes: 60
delivery_step:
id: send_slack
type: action
action: slack_module.Slack.send_incident_message
input:
channel: "{{ context.channel }}"
message: "Approval required: {{ context.approval_link }}"- Generates an approval link like
/approve/<workflow_uid>/<step_id> - Blocks until user approves or timeout hits
- Optional
delivery_stepsends notification before blocking
Launches a dynamic form (React-based) and waits for user input.
- id: gather_info
type: webform
module: webform
config_file: wf_config.js
css_file: custom.css
timeout_minutes: 120
delivery_step:
id: notify_link
type: action
action: slack_module.Slack.send_message
input:
channel: "{{ context.channel }}"
message: "Please complete the form: {{ context.approval_link }}"- Form is configured by the passed
config_file - Submitted data is returned into context under
register_output - Form results are available in the context just like any other step results
You can make any step conditional using the conditions field.
conditions:
- name: only run if mode is prod
condition: "{{ context.mode }} == 'prod'"Each condition is templated and evaluated as a Python bool.
If any condition is false, the step is skipped.
As an alternative to context.step_results you can use this to define or derive additional context variables after a step runs.
register_vars:
- name: severity
conditional:
- if: "{{ context.fetch_result.status == 'error' }}"
value: sev1
- elif: "{{ context.fetch_result.status == 'warning' }}"
value: sev2
- default: infoYou can also assign simple values:
- name: api_status
value: "{{ context.fetch_result.status }}"- id: get_data
type: action
action: api_module.Api.fetch_data
input:
url: "https://api.example.com"
register_output: api_result
register_vars:
- name: status
value: "{{ context.api_result.status }}"
- id: alert_ops
type: action
conditions:
- name: only on failures
condition: "{{ context.status }} == 'error'"
action: slack_module.Slack.send_incident_message
input:
channel: "#ops"
message: "API call failed"Koreflow provides a powerful context system that lets you:
- Store results of steps
- Define new variables
- Compute conditional values
- Access all data using
{{ context.var }}syntax
All variables live in the context, which is shared across the entire workflow execution.
Every action, approval, or webform step can capture its result into the context:
- id: fetch_user
type: action
action: user_module.User.get_user_info
input:
username: "{{ context.user }}"
register_output: user_infoThis saves the method’s return value as context.user_info.
You can reference it later:
input:
message: "User email: {{ context.user_info.email }}"Use this to define additional variables derived from previous results.
You can use:
register_vars:
- name: email
value: "{{ context.user_info.email }}"register_vars:
- name: severity
conditional:
- if: "{{ context.user_info.role == 'admin' }}"
value: "sev1"
- elif: "{{ context.user_info.role == 'manager' }}"
value: "sev2"
- default: "info"If the value can’t be rendered (e.g., missing field), you can choose to fail or ignore:
register_vars:
- name: user_id
value: "{{ context.user_info.id }}"
absent_action: ignoreDefault is fail, which throws an error if the value can't be resolved.
- Variables are accessible as
{{ context.var_name }} - All parsed values (from payload) and registered outputs are stored here
- Avoid overwriting context keys unless intentional
- Use
register_outputwhen you need the full method return - Use
register_varswhen you want to extract or compute smaller pieces
If you use a webform, the form result is stored as structured data:
- id: gather_info
type: webform
...
register_output: form_resultThe result will be available as:
context.form_result.form_data.<field_id>You can use register_vars to extract individual fields:
register_vars:
- name: dc
value: "{{ context.form_result.form_data.regular_data_center }}"- id: get_status
type: action
action: api_module.Api.check_status
input:
id: "{{ context.ticket_id }}"
register_output: api_response
register_vars:
- name: is_resolved
conditional:
- if: "{{ context.api_response.status == 'closed' }}"
value: true
- default: falseWebforms in Koreflow are a native way to collect structured, multi-step user input as part of a workflow. Whether you’re gathering deployment metadata, provisioning details, or human-verified parameters — webforms help turn vague approvals into real, validated input.
This is your 🟦 Ask block in the Operational Blocks Framework (OBF), and it follows the UX principles of minimalism with power and progressive disclosure, as described in Letters from the Trenches.
- id: gather_input
type: webform
module: webform
config_file: wf_env_selection.js
css_file: custom.css
timeout_minutes: 60
delivery_step:
id: notify_user
type: action
action: slack_module.Slack.send_info_message
input:
channel: "{{ context.channel }}"
title: "Input Required"
message: "Please complete this form: {{ context.approval_link }}"
register_output: form_result- You specify which form config to use via
config_filein the step. - Koreflow renders the webform using a React-based dynamic wizard, and serves it at a unique per-workflow link.
- The form can include inputs, dropdowns, API-triggers, conditional logic, or even error-handling pages.
- Once submitted, the data becomes part of the workflow’s
context.
Each webform is defined in a JavaScript module (e.g. wf_env_selection.js) that exports a wizardConfig object. This describes the form’s structure, steps, logic, and behavior.
You can create different config files for different use cases:
wf_deploy.jsfor production rolloutswf_escalation.jsfor incident escalationwf_env_selection.jsfor environment provisioning
These are true UX-driven forms — no boilerplate HTML, no frontend development needed.
The form follows principles laid out in Letters from the Trenches:
- Minimalism: Each screen focuses on one step, one question.
- Progressive Disclosure: Complex logic is revealed only as needed.
- Human-readable fields with icons, clear labels, and simple navigation.
- Valid JSON output, injected directly into workflow context.
Let’s walk through a realistic multi-path scenario (renamed to be infrastructure-agnostic):
const wizardConfig = {
steps: [
{
id: "start",
type: "junction",
question: "What do you want to configure?",
iconName: "Settings",
options: [
{ label: "Environment Setup", nextStep: "env_type" },
{ label: "Service Validation", nextStep: "validation_entry" }
]
},
{
id: "env_type",
type: "dropdown",
label: "Environment Type",
iconName: "Environment",
options: ["Dev", "QA", "Staging", "Prod"],
nextStep: "select_region"
},
{
id: "select_region",
type: "dropdown",
label: "Select Region",
options: ["us-west", "us-east", "eu-west", "ap-south"],
iconName: "Globe",
nextStep: "owner_email"
},
{
id: "owner_email",
type: "input",
label: "Team Contact Email",
iconName: "Mail",
nextStep: "multi_services"
},
{
id: "multi_services",
type: "multiinput",
label: "Add Required Services",
max_inputs: 5,
iconName: "Service",
nextStep: "need_ticket"
},
{
id: "need_ticket",
type: "junction",
iconName: "Jira",
label: "Need to create a ticket?",
options: [
{ label: "Yes", nextStep: "ticket_description" },
{ label: "No", nextStep: "submit_ticket_id" }
]
},
{
id: "submit_ticket_id",
type: "input",
iconName: "Jira",
label: "Existing Ticket ID",
nextStep: "submit"
},
{
id: "ticket_description",
type: "textbox",
iconName: "Jira",
label: "Ticket Description",
nextStep: "submit"
},
{
id: "validation_entry",
type: "input",
label: "Enter service name",
iconName: "Service",
nextStep: "trigger_check"
},
{
id: "trigger_check",
type: "api-trigger",
label: "Run Validation",
iconName: "Rocket",
apiCall: "/api/validate_service",
payloadField: "validation_entry",
successNextStep: "submit",
failureNextStep: "error_step"
},
{
id: "error_step",
type: "info",
label: "Validation Failed",
text: "Something went wrong. Please fix input or contact support.",
nextStep: "validation_entry"
},
{
id: "submit",
label: "Submit Form",
headers: { "Content-Type": "application/json" },
type: "submit"
}
]
};
export default wizardConfig;Once submitted, this form yields:
{
"status": "approved",
"form_data": {
"env_type": "QA",
"select_region": "us-west",
"owner_email": "[email protected]",
"multi_services": ["auth", "billing"],
"ticket_description": "Need this deployed by EOD"
}
}Which becomes accessible in your workflow via:
{{ context.form_result.form_data.multi_services }}
{{ context.form_result.status }}Koreflow includes a built-in notification system, with first-class support for Slack messages, alerts, and approvals. Notifications are handled via modules (e.g. slack_module) and declared using action steps in your workflow YAML.
You can use Slack notifications to:
- Alert on form submissions
- Request approvals
- Report status updates
- Send structured information
- id: notify_ops
type: action
action: slack_module.Slack.send_info_message
input:
channel: "{{ context.channel }}"
title: "New Submission Received"
message: "Workflow {{ context.workflow_uid }} submitted successfully."
register_output: notify_resultYour Slack module can expose multiple methods — for example:
methods:
- send_info_message
- send_incident_messageThese are defined in the module’s Slack.py file and registered in module.yaml.
For general info messages with a title and body:
input:
channel: "#ops-alerts"
title: "Something happened"
message: "Here's what you need to know..."Used for severity-based alerts:
input:
channel: "#incident-response"
message: "Critical alert for service XYZ"
severity: "sev2"
oncall_user: "{{ context.user }}"The severity field maps to specific Slack color codes for attachments.
You can send structured data as a list of labeled fields, like this:
input:
channel: "#ops"
title: "Deployment Details"
keyed_message:
- key: "Environment"
value: "{{ context.env_type }}"
- key: "User"
value: "{{ context.user }}"
- key: "Form Status"
value: "{{ context.form_result.status }}"This is rendered as a Slack message attachment with labeled fields:
┌───────────────────────┐
│ Deployment Details │
├────────────┬──────────┤
│ Environment│ prod │
│ User │ yuri │
│ Form Status│ approved │
└────────────┴──────────┘
You can combine both:
input:
channel: "#alerts"
title: "Form Submitted"
message: "A new request was submitted"
keyed_message:
- key: "Region"
value: "{{ context.form_result.form_data.select_region }}"
- key: "Email"
value: "{{ context.form_result.form_data.owner_email }}"- Always template dynamic values via
{{ context.var }}. - Use
keyed_messagefor structured display. - For approvals, use Slack in combination with
approvalorwebformsteps.
Koreflow is designed for durability and observability. Every workflow run is tracked, persisted, and can be resumed if interrupted — with full introspection via structured logs and state files.
Logs are written per component, stored in the ./logs folder. Key files:
| File | Purpose |
|---|---|
logs/workflow_engine.log |
Core engine execution (step-by-step) |
logs/match_engine.log |
Match logic evaluation details |
logs/approval_manager.log |
Approvals, webforms, and status tracking |
logs/slack_module.log |
Slack integration, webhooks, errors |
logs/flask_app.log |
Web server, routing, module serving |
Each log uses structured [timestamp] [component] format for easy grepping.
Every running workflow creates a lifetime file under:
lifetimes/<workflow_uid>.yaml
This file captures:
- Workflow definition
- Context variables
- Current step
- Reason for last update
- Timestamps
When a workflow is completed, it’s automatically moved to:
lifetimes/completed/
These files act as both execution trace and recovery anchor.
On startup, Koreflow scans all incomplete workflows in the lifetimes/ directory and resumes them automatically:
def resume_pending_workflows():
runs = discover_recoverable_runs()
for run in runs:
threading.Thread(
target=resume_workflow_from_lifetime,
args=(run, approval_manager),
daemon=True
).start()The engine picks up at the last unfinished step, with full context reloaded.
| Symptom | Check |
|---|---|
| Workflow doesn’t trigger | match_engine.log — see match result |
| Form never completes | Make sure submit hits the /submit endpoint and includes UID/step ID |
| Output missing | Check register_output or register_vars placement |
| Approval stuck | Ensure approval route exists and wasn’t timed out |
| Form shows error | JS console in browser; check config path or missing assets |
Koreflow is modular by design. Each step in a workflow calls a method from a module, which is just a Python class.
You can think of a module as a plug-in — it provides reusable logic that can be invoked declaratively from YAML.
- Send Slack messages
- Call internal APIs
- Provision infrastructure
- Post JIRA tickets
- Validate input
- Interact with GitHub, Datadog, PagerDuty, etc.
You’ll want to create a module when:
- A piece of logic is reusable across multiple workflows
- It needs secrets/configuration (e.g., API keys)
- It calls external systems
- It encapsulates multi-step behavior (e.g., sending + logging + retry)
Koreflow modules are:
- Self-contained
- Configurable
- Type-safe (by convention, not strict typing)
- And automatically validated during workflow load.
- Be placed under
modules/<your_module_name>/ - Contain a
module.yamlmanifest - Include a Python file with a class and one or more callable methods
- Optionally: provide
config.yaml, assets, or helper files
repos/modules/slack_module/
├── slack.py # Main module logic
├── module.yaml # Describes class and methods
└── usage_reference.yaml # Usage examplesIn module.yaml:
name: slack_module
class: Slack
methods:
- send_info_message
- send_incident_messageIn slack.py:
class Slack:
def __init__(self, context):
self.context = context
self.config = get_config() # Loads from config.yaml
def send_info_message(self, channel, title, message):
...Koreflow’s execution engine dynamically invokes your module’s Python methods based on the YAML workflow definition.
Every action step in your workflow calls:
<module>.<Class>.<method>
Let’s break down how each of these is tied together:
The module name in your YAML DSL corresponds to the folder name inside modules/.
action: slack_module.Slack.send_info_message- Koreflow looks for a directory:
modules/slack_module/
Inside repos/modules/slack_module/, you must have a module.yaml like:
name: slack_module
class: Slack
methods:
- send_info_message
- send_incident_messageclass→ the Python class name defined inSlack.pymethods→ list of callable methods allowed in YAML
🔐 This manifest is validated at startup to prevent calling unsafe or undefined methods.
You must have a file like slack.py that contains the specified class and methods:
class Slack:
def __init__(self, context):
self.context = context # full context injected
def send_info_message(self, channel, title, message):
...Koreflow creates one instance per workflow execution and calls the method dynamically using reflection.
In your YAML:
- id: notify
type: action
action: slack_module.Slack.send_info_message
input:
channel: "#ops"
title: "Incident Update"
message: "Something failed"This triggers:
modules/slack_module/Slack.py → Slack.send_info_message(channel, title, message)
Arguments in input: must match method parameters. You can also use Jinja templates with {{ context.variable }}.
| DSL Element | Tied To |
|---|---|
action: |
Path to module.class.method |
modules/ |
Must contain a folder matching the module name |
module.yaml |
Declares class: and methods: |
| Python method args | Declared via input: in the workflow step |
context injection |
Automatically passed into class constructor |
register_output: |
Receives return value of method (any Python object) |
Each Koreflow module is a plain Python class that defines one or more callable methods. These methods can be used in workflows as action steps.
Modules are instantiated per workflow run and are provided the full context dictionary automatically.
from slack_sdk import WebClient
from engine.utils.logs import get_logger
from slack_module.config_getter import get_config
logger = get_logger("slack_module")
class Slack:
def __init__(self, context):
self.context = context
self.config = get_config() # from config.yaml
self.client = WebClient(token=self.config["slack_token"])
def send_info_message(self, channel, title, message):
logger.info(f"Sending info message to {channel}")
response = self.client.chat_postMessage(
channel=channel,
text=title,
attachments=[
{
"color": "#36a64f",
"fields": [
{
"title": "Message",
"value": message or "N/A",
"short": True
}
]
}
]
)
return {
"status": "sent",
"ts": response["ts"],
"channel": channel
}| Rule | Description |
|---|---|
__init__(self, context) |
Required. Receives the workflow context object |
| Method names | Must match those listed in module.yaml → methods: |
| Method args | Declared in input: block in the workflow YAML |
| Method return | Must be a dictionary with {'status', 'message', 'data'} |
Remember handling exceptions gracefuly, letting the use define how to handle an error at the workflow level, rather then deciding for him
When a workflow runs:
- id: notify
type: action
action: slack_module.Slack.send_info_message
input:
channel: "#ops"
title: "Incident Update"
message: "Something failed"The engine does this internally:
instance = Slack(context)
result = instance.send_info_message(
channel="#ops",
title="Incident Update",
message="Something failed"
)If your method signature doesn’t match the input: fields — execution will fail.
- Make sure
input:values match method parameters exactly. - Use
self.context.get("key")to access context dynamically inside the module. - You can log intermediate values with
logger.debug(...)orlogger.info(...).
Every module in Koreflow must include a manifest file named module.yaml.
This file tells the engine:
- What class to load
- Which methods are safe to call from YAML
- How to locate the module
It acts as both a contract and a safety mechanism — allowing only explicitly declared methods to be called.
name: slack_module
class: Slack
methods:
- send_info_message
- send_incident_message| Field | Required | Description |
|---|---|---|
name |
✅ | Must match the folder name in modules/ |
class |
✅ | The Python class in your module file (slack.py) |
methods |
✅ | A list of allowed methods exposed to YAML DSL |
modules/slack_module/
├── slack.py
├── module.yaml
├── config.yaml # optional
└── utils/ # optionalKoreflow uses the manifest for safe reflection. This prevents:
- Accidental method exposure
- Unsafe calls like
os.system()being reachable - Typos in workflow files
If a method is not listed in methods:, it cannot be called — even if it exists in the class.
The file containing your class (e.g., Slack) does not need to be named after the module folder. Koreflow will scan all .py files in the module directory and locate the one that defines the class specified in module.yaml.
For example:
modules/slack_module/
├── slack.py # Contains class Slack
├── module.yaml # Declares class: Slack
...Koreflow loads each .py file and checks if it contains the expected class. If the class is found, it is used to execute declared methods.
Flexible naming for Python files (e.g., slack.py, not slack_module.py)
- Support for modular folder structures
- Better readability and maintainability This behavior ensures your module structure remains intuitive without sacrificing declarative clarity in the manifest.
- Keep
methods:list minimal and intentional - One class per module (for now) — use
utils/subfolder in the module directory for helpers - Always validate
module.yamlbefore committing
While not yet implemented, the manifest may support:
- Input validation schemas
- Descriptions for methods (for auto-doc generation)
- Method metadata (e.g., async support, retries)
- Access control (e.g., public vs internal methods)
Before any workflow executes, Koreflow performs a preflight validation step. This ensures that all referenced modules, classes, and methods are valid and callable — preventing broken workflows from reaching runtime.
When the engine boots or a workflow is triggered, it checks:
| Checkpoint | What It Verifies |
|---|---|
module.yaml |
Exists and is valid YAML |
name field |
Matches the folder name |
class field |
The Python class exists in the expected file |
methods list |
Each method is callable on the class |
input args |
(At runtime) match the method’s signature |
If anything is off, the workflow will be rejected early, with an informative log message.
# module.yaml
name: slack_module
class: Slack
methods:
- send_info_message
- send_incident_message# Slack.py
class Slack:
def __init__(self, context):
self.context = context
def send_info_message(self, channel, title, message):
...
def send_incident_message(self, channel, message, severity=None, oncall_user=None):
...When a workflow references:
action: slack_module.Slack.send_incident_messageThe engine verifies:
modules/slack_module/existsslack.pycontains a classSlack- That class has a method
send_incident_message - It’s listed in
module.yaml
✅ If all passes — the step is executed normally.
❌ If not — the workflow fails fast with a clear error.
| Error Message | Likely Cause |
|---|---|
Module folder not found |
Typo in the action: module name |
Class not found |
class: value doesn’t match actual class |
Method not found |
Method missing from module.yaml or not defined |
Invalid module.yaml |
Malformed YAML or missing keys |
A Koreflow workflow is defined in YAML under the top-level workflow: key.
For more in-depth DSL reference check docs/dsl.md file
workflow:
name: cert_provisioning
match: {...} # Optional: match payload
payload_parser: [...] # Optional: extract vars from payload
context_variables: [...] # Optional: static or default values
context_modules: {...} # Optional: reusable module instances
steps: [...] # Main logic stepsThe name of the workflow.
Conditionally runs workflow based on incoming payload.
match:
conditions:
- path: payload.alert.severity
operator: equals
value: "critical"
id: is_critical
condition_logic: is_criticalExtracts and maps values from payload into context.
payload_parser:
- path: payload.alert.source
var: source
- path: payload.user
var: triggered_by
absent_action: ignoreDeclares variables used during execution.
context_variables:
- name: channel
default: "#ops"
- name: env
default: "staging"Instantiates long-lived modules (e.g. Git, Chatbot) once and reuses them.
context_modules:
git:
module: git_module.Git
repo: "https://github.com/org/repo.git"
branch: "feature/{{ context.env }}"
base_branch: "main"
work_dir: "/tmp/gitops"
handle_existing_branch: "pull"Each step in steps: drives the workflow. The engine will run steps in order, optionally skipping based on conditions.
steps:
- id: send_slack
type: action
action: slack_module.Slack.send_info_message
input:
channel: "{{ context.channel }}"
title: "Deployment started"
message: "Triggered by {{ context.triggered_by }}"| Key | Description |
|---|---|
id |
Unique ID for step |
type |
One of: action, approval, webform |
action |
If type: action, points to module.Class.method |
input |
Dictionary passed as kwargs to method |
conditions |
(Optional) Determines whether step runs |
condition_logic |
Boolean logic for the conditions |
register_output |
Stores method result in context |
register_vars |
Defines new variables in context |
delivery_step |
Only for approval/webform – sends the approval link |
- id: send_slack
type: action
action: slack_module.Slack.send_info_message
input:
channel: "#alerts"
title: "⚠️ Critical Alert"
keyed_message:
- key: "Source"
value: "{{ context.source }}"
- key: "Env"
value: "{{ context.env }}"
- id: restart_service
type: action
action: api_module.API.call
input:
method: "POST"
url: "https://api.internal/restart"
body:
service: "nginx"
env: "{{ context.env }}"- id: deploy_notification
type: action
action: slack_module.Slack.send_info_message
input:
channel: "{{ context.channel }}"
title: "🟡 Ready to Deploy"
message: "Click to approve: <{{ context.approval_link }}|Approve Deploy>"
- id: deploy_approval
type: approval
message: "Deploy to {{ context.env }}?"
timeout_minutes: 120
delivery_step: deploy_notification
- id: trigger_deploy
type: action
action: api_module.API.call
input:
method: "POST"
url: "https://cd.system/deploy"
body:
env: "{{ context.env }}"- id: provision_form
type: webform
module: webform
config_file: cert_form.js
css_file: custom.css
timeout_minutes: 120
delivery_step:
id: form_notify
type: action
action: slack_module.Slack.send_info_message
input:
channel: "#ssl-team"
message: "Complete form: <{{ context.approval_link }}|Cert Request>"
- id: ask_chatbot
type: action
action: chatbot_module.Chatbot.ask
input:
provider: "openai"
model: "gpt-4"
system_prompt: "Summarize cert request."
user_message: "{{ context.form_result | tojson }}"
api_key: "{{ context.openai_key }}"
- id: notify_summary
type: action
action: slack_module.Slack.send_info_message
input:
channel: "#ssl-team"
message: "{{ context.step_results.ask_chatbot.data.reply }}"context_modules:
git:
module: git_module.Git
repo: "https://github.com/org/envs.git"
branch: "env-{{ context.user }}"
base_branch: "main"
work_dir: "/tmp/git-envs"
handle_existing_branch: "fail"
steps:
- id: render_env_file
type: action
action: context.git.add_file_from_template
input:
template: "env.yaml.j2"
destination: "envs/{{ context.user }}/main.yaml"
- id: open_pr
type: action
action: context.git.open_pr
input:
title: "Create env for {{ context.user }}"
body: "Auto-generated"
- id: approve_env
type: approval
message: "Approve env creation?"
timeout_minutes: 60
delivery_step:
id: alert_env
type: action
action: slack_module.Slack.send_info_message
input:
channel: "#platform"
message: "Approve PR: <{{ context.approval_link }}|View>"
- id: merge_env
type: action
action: context.git.merge_pr- id: approval_step
type: webform
module: webform
config_file: gate_form.js
register_output: gate_data
timeout_minutes: 30
- id: skip_if_denied
type: action
action: slack_module.Slack.send_info_message
conditions:
conditions:
- path: context.gate_data.status.form_data.approval
operator: not_equals
value: "yes"
id: is_denied
condition_logic: is_denied
input:
message: "Gate not approved. Aborting."
- id: continue_if_approved
type: action
action: slack_module.Slack.send_info_message
conditions:
conditions:
- path: context.gate_data.status.form_data.approval
operator: equals
value: "yes"
id: is_approved
condition_logic: is_approved
input:
message: "Gate approved. Continuing execution."You can access:
context.form_result.status.form_data.key
context.approval_link
context.chatbot_reply.reply
context.git_pr_result.url
context.payload.alert.detailsUse context.step_results.<step id> or register_output to capture the result of a step, and reference it later.
I built Koreflow around a philosophy of operational clarity, control, and composability. The engine was built to solve real-world problems that we face every day: duct-taped scripts, manual approvals, brittle integrations, jungle of custom scripts etc.
You need no coding expertise to define your workflows in YAML, but you have a framework or extending them with custom modules.
Koreflow gives you full control — declarative, extensible, observable, testable.
It scales from the simplest Slack notifier to complex, stateful automations involving human input, approvals, and external systems.
You now have the framework.
Use it to productize your operations, close the loop between people and automation, and build workflows that make sense.
I’d love to see this project grow and find its place in the community. You're more than welcome to contribute — as long as your contributions honor the core philosophy.
Cheers! Yuri
Koreflow is licensed under the GNU Affero General Public License v3.0 (AGPLv3).
If you use Koreflow or its derivatives in a commercial product or service (including SaaS), a revenue-sharing agreement applies. You must either:
- Open source your derived work under AGPLv3, or
- Contact the author to arrange a revenue share license.