Workflow Definition Reference
A workflow definition is a JSON document describing a graph
of steps and connections. It is passed as a JSON-encoded
string in the definitionJson field when saving
via the API.
Envelope
{
"kind": "channelos.workflow",
"schemaVersion": "1.0",
"workflowId": "",
"name": "my_workflow",
"description": "Optional description",
"graph": {
"nodes": {},
"edges": {}
}
}
| Field | Description |
|---|---|
kind |
Always "channelos.workflow" |
schemaVersion |
Always "1.0" |
workflowId |
Empty string for new workflows; returned by the server on save |
name |
Unique name within the namespace |
graph |
Node and edge graph — see below |
Graph structure
The graph contains two maps: nodes and
edges. Keys are arbitrary unique IDs.
Node object
"my_step": {
"id": "my_step",
"label": "My step",
"actionType": "integration.http_request",
"config": {
"url": "https://api.example.com/notify",
"method": "POST"
},
"position": { "x": 260, "y": 40, "width": 160, "height": 68 }
}
| Field | Description |
|---|---|
id |
Unique node ID. Used in edge definitions and expressions |
label |
Display label (shown in flow editor) |
actionType |
Action type — see all types below |
config |
Action-specific config — see per-action docs |
position |
Layout coords (managed by flow editor; optional for programmatic use) |
Edge object
"e1": {
"id": "e1",
"source": { "nodeId": "start", "portId": "out" },
"target": { "nodeId": "notify", "portId": "in" }
}
| Field | Description |
|---|---|
source.portId |
Common values: out,
output, true,
false, else (switch
fallback), case value
|
target.portId |
Usually in or input |
type |
normal (default),
fork (parallel branch),
join (parallel merge)
|
Data flow between steps
Every step receives a single JSON object called
input. That object is the output of the previous step — or
the workflow's start payload for the first step. Most steps
pass their output forward unchanged; steps that explicitly
produce new data replace the flowing payload.
Passthrough steps
These steps do not modify the payload. The next step
receives exactly the same input as the
passthrough step did:
-
flow.start— passes the instance start payload forward -
logic.condition— routes but does not changeinput -
logic.switch— routes but does not changeinput -
logic.parallel— every branch receives the sameinput -
timing.delay/timing.cron— timer only; payload unchanged -
debug.log— logs only; payload unchanged
Steps that produce new data
The following steps replace the flowing payload with their own output. Everything after them sees the new shape.
| Step |
What the next step receives as input
|
|---|---|
data.transform |
The resolved mapping object |
data.compute |
The computed return value |
data.build_record |
The assembled key-value object |
integration.http_request |
{ "status": 200, "body": { ... } } |
communication.send_message |
{ "sent": true, "channel": "...", "to": "..."
}
|
task.manual |
Whatever JSON the human submits when completing the task |
signal.wait |
The signal payload sent via the runtime API |
logic.join |
Merged object of all branch outputs — see below |
logic.foreach_join |
{ "results": [ iter1Output, iter2Output, ... ]
}
|
Parallel branches and logic.join output
When branches converge at a logic.join, their
outputs are merged into a single object. The key for each
branch is taken from the label of the
incoming edge. Give every edge from
logic.parallel to the body a distinct label;
the join output will mirror those labels:
// edge from parallel → fetch_employees has label "employees"
// edge from parallel → fetch_rules has label "rules"
// input to the node after join:
{
"employees": { /* fetch_employees output */ },
"rules": { /* fetch_rules output */ }
}
If any incoming edge has no label, the branches are merged
positionally as
{ "branches": [ branch0, branch1, ... ] }.
Always label your parallel edges to get predictable named
keys.
Foreach loop — item access
Inside a logic.foreach loop body, the current
iteration item is always available at
input.item. The original array container is
also accessible as a sibling at its collection path:
// foreach config: { "collection": "recipients" }
// input.item → current recipient object
// input.recipients → the full recipients array (still accessible)
// example in a send_message step inside the loop:
"to": "{{input.item.email}}"
Referencing earlier steps
Any step processed by the TypeScript runtime (data /
integration / communication / task) records its output in
the step store. You can reference those outputs from any
later step using {{steps.stepId.field}}:
// step id "fetch_user" returned { "name": "Alice", "plan": "pro" }
"subject": "Hello {{steps.fetch_user.name}}"
// nested fields:
"body": "Plan: {{steps.fetch_user.plan}}"
Note: Engine-side steps
(flow.*, logic.*,
timing.*, signal.*) are not
tracked in the step store and cannot be referenced via
{{steps.*}}. To access the original workflow
start payload at any point in the flow, use
{{instance.input.field}}. To preserve other
intermediate values across branches or after a join, use
data.transform as a capture step.
Edge portId — when it matters
For most steps, source.portId is only used by
the visual editor for layout and can be any string. It has
routing significance for exactly two step types:
| Step | Required source.portId values |
|---|---|
logic.condition |
"true" or "false" |
logic.switch |
The literal string the expression evaluates to, plus
"else" for the default branch
|
target.portId is always ignored at runtime.
Expressions
Runtime actions (data / integration /
communication / task) support
{{...}} expressions inside string config
values. Engine actions (logic / flow /
timing / signal) use direct input.field syntax
without braces.
| Expression | Description |
|---|---|
{{input.field}} |
Field from the current step's input payload |
{{instance.input.field}} |
Field from the original workflow start payload — constant for the entire instance lifetime |
{{steps.step_id.field}} |
Output field from a completed earlier step |
{{env.VAR_NAME}} |
Secret / env var from the namespace secrets store |
{{now}} |
ISO 8601 timestamp at evaluation time |
{{today}} |
Current date as YYYY-MM-DD |
Expressions can appear inside any string config value:
"body": "Order {{input.orderId}} confirmed"
Engine actions
These run in the deterministic C++ workflow engine.
Expression syntax: input.field (no
{{ }}).
flow.start
Entry point. Every workflow must have exactly one. No config.
{ "actionType": "flow.start", "config": {} }
flow.end
Terminal node. The instance completes when reached. No config.
{ "actionType": "flow.end", "config": {} }
flow.subprocess
Start a child workflow and wait for it to complete. The
parent instance parks until the child reaches
flow.end, then resumes with the child's final
output. (Previously named flow.run.)
{
"actionType": "flow.subprocess",
"config": {
"workflowId": "2",
"version": "deployed",
"inputJson": "{}"
}
}
| Field | Required | Description |
|---|---|---|
workflowId |
yes | Numeric ID of the target workflow |
version |
no |
"deployed" uses the currently deployed
version
|
inputJson |
no | Static JSON string passed to the spawned workflow |
logic.condition
Branch on a boolean expression. Engine syntax — no
{{ }} wrappers.
{
"actionType": "logic.condition",
"config": { "expression": "input.amount > 1000" }
}
| Field | Required | Description |
|---|---|---|
expression |
yes |
Boolean expression. Engine dot-path syntax:
input.field. Supports arithmetic and
comparison operators
|
Output ports: true, false
logic.switch
Route to one of N branches based on the value of an
expression. Connect outgoing edges with
portId equal to the expected case value. Use
"else" port for the no-match case.
{
"actionType": "logic.switch",
"config": { "expression": "input.status" }
}
| Field | Required | Description |
|---|---|---|
expression |
yes |
Expression whose result is matched against outgoing
edge portId values. Unmatched cases
route through the "else" port
|
logic.parallel
Fork into multiple concurrent branches simultaneously.
Connect outgoing edges with type: "fork". Pair
with logic.join. No config.
{ "actionType": "logic.parallel", "config": {} }
logic.join
Wait for all parallel branches to arrive before continuing.
Incoming edges use type: "join". No config.
{ "actionType": "logic.join", "config": {} }
logic.foreach
Iterate over an array. Runs the loop body once per item. The
current item is always available as input.item;
the full parent payload (including the source array) remains
accessible at input.*.
{
"actionType": "logic.foreach",
"config": { "collection": "items" }
}
| Field | Required | Description |
|---|---|---|
collection |
no |
Dot-path to the array within the step input.
Default: "items". The array itself
remains accessible at
input.<collection> inside the
loop body
|
Inside the loop body:
input.item is the current element (e.g.
{{input.item.email}}). The source array is also
still readable (e.g. {{input.items}} if
collection is "items").
Output port: out — connect to the first
node in the loop body. Pair with
logic.foreach_join.
logic.foreach_join
Marks the end of the loop body. Triggers the next iteration, or exits when all items are done. No config.
{ "actionType": "logic.foreach_join", "config": {} }
Output: { "results": [...] } — array of
each iteration body's final output. Output port:
out.
timing.delay
Pause the workflow for a fixed duration in milliseconds. No threads are held; the instance is parked in the engine.
{
"actionType": "timing.delay",
"config": { "duration_ms": 3600000 }
}
| Field | Required | Description |
|---|---|---|
duration_ms |
yes | Pause duration in milliseconds. The instance is parked; no threads are held |
Common values: 5000 (5s) |
60000 (1m) |
3600000 (1h) |
86400000 (24h)
timing.cron
Schedule periodic execution. The instance parks until the
next cron tick fires, then completes. The engine
automatically restarts the workflow on the next tick. Must
be placed as the first node after flow.start.
{
"actionType": "timing.cron",
"config": {
"cron": "0 9 * * MON-FRI",
"timezone": "Europe/Kyiv"
}
}
| Field | Required | Description |
|---|---|---|
cron |
yes | 5-field expression: minute hour day month weekday |
timezone |
no | IANA timezone name (display only — engine schedules in UTC) |
inputJson |
no | Static JSON passed as input on each tick |
signal.wait
Suspend the instance until a named external signal is received via POST /api/v1/runtime/signal. Signal payload becomes this step's output.
{
"actionType": "signal.wait",
"config": { "signal": "approved" }
}
| Field | Required | Description |
|---|---|---|
signal |
no | Name of the signal to wait for. Defaults to the step ID if omitted |
If signal is omitted, the step ID is used as
the signal name. Output port: out.
signal.ask
Send a prompt message, park the instance until a signal arrives, then validate the received value. Retries automatically with an error message until validation passes.
{
"actionType": "signal.ask",
"config": {
"prompt": "Enter your date of birth (YYYY-MM-DD):",
"errorPrompt": "Invalid date, please try again:",
"validate": "date",
"channel": "telegram",
"to": "{{instance.input.chatId}}"
}
}
| Field | Required | Description |
|---|---|---|
prompt |
yes | Message text sent to the user as the question. |
errorPrompt |
no | Sent when validation fails. Defaults to re-sending the prompt. |
signal |
no |
Signal to wait for. Default:
"user_input".
|
validate |
no |
nonempty | number |
date | none. Default:
none.
|
channel |
no |
Messaging channel, e.g. "telegram".
|
to |
no | Recipient address or chat ID. Defaults to session chat. |
valueKey |
no |
Key to extract from the signal payload. Default:
"text".
|
Output: the validated value as
{ "value": <input> }.
signal.send_to_session
Send a named signal to the primary workflow instance associated with a session. Useful in sub-workflows to wake the parent after completing an operation.
{
"actionType": "signal.send_to_session",
"config": {
"sessionId": "{{instance.input.sessionId}}",
"signal": "operation_result"
}
}
| Field | Required | Description |
|---|---|---|
sessionId |
yes | Session ID used to look up the primary instance to signal. |
signal |
yes | Name of the signal to emit on the target instance. |
The current step input is passed as the signal payload.
Output port: out.
Data transformation
These run in the TypeScript runtime. Config string fields
support {{...}} expressions.
data.transform
Reshape the payload. Each value in the
mapping object is resolved as an expression.
{
"actionType": "data.transform",
"config": {
"mapping": {
"fullName": "{{input.firstName}} {{input.lastName}}",
"email": "{{input.email}}",
"total": "{{steps.calc.total}}"
}
}
}
| Field | Required | Description |
|---|---|---|
mapping |
yes |
Object where each value is a
{{...}} expression. Non-expression
values are passed through as-is
|
Output: the resolved mapping object.
data.compute
Evaluate a JavaScript expression in a sandboxed VM. Write
raw JS — no {{ }}.
{
"actionType": "data.compute",
"config": { "expression": "input.qty * input.price * 1.2" }
}
| Field | Required | Description |
|---|---|---|
expression |
yes |
Raw JavaScript expression. No
{{ }} wrappers. Return value
becomes the step output
|
Available: input, steps,
Math, Date, JSON,
Array, Object, standard type
constructors. Output: computed value.
data.build_record
Assemble a new object from named key-value pairs.
{
"actionType": "data.build_record",
"config": {
"fields": [
{ "key": "name", "value": "{{input.name}}" },
{ "key": "label", "value": "Order {{input.orderId}}" }
]
}
}
| Field | Required | Description |
|---|---|---|
fields |
yes |
Array of { key, value } objects. Values
support {{...}} expressions. Output is
an object with the assembled key-value pairs
|
data.render_template
Render a multi-line Handlebars template. The step's
input is the template data context.
{
"actionType": "data.render_template",
"config": {
"template": "Hello {{name}},\n\nYour balance is {{balance}} USD.",
"outputKey": "text"
}
}
| Field | Required | Description |
|---|---|---|
template |
yes |
Handlebars source. References input fields directly:
{{fieldName}}
|
outputKey |
no |
Wrap output under this key. Default:
"text". Set to "" for raw
string
|
Special helpers: {{today}} (YYYY-MM-DD),
{{now}} (ISO datetime)
data.format_text
Render a single-line Mustache-style template. References
input fields via {{field}}.
{
"actionType": "data.format_text",
"config": {
"template": "Hello {{name}}, your report for {{today}} is ready.",
"outputKey": "text"
}
}
| Field | Required | Description |
|---|---|---|
template |
yes |
Mustache template string. References step input
fields via {{field}}
|
outputKey |
no |
Wrap the rendered string under this key. Default:
"text". Set to "" for raw
string
|
data.filter_list
Filter an array to items that satisfy a condition.
{
"actionType": "data.filter_list",
"config": {
"source": "orders",
"field": "status",
"operator": "equals",
"value": "pending",
"caseSensitive": false
}
}
| Field | Required | Description |
|---|---|---|
source |
no | Dot-path to the array inside input |
field |
no | Field inside each item to test |
operator |
yes |
equals | not_equals |
contains | not_contains |
starts_with | ends_with |
is_empty | is_not_empty |
greater_than | less_than
|
value |
no | Comparison value |
caseSensitive |
no | Default: false |
Output: { "items": [...] }
data.sort_list
Sort an array by a field.
{
"actionType": "data.sort_list",
"config": {
"source": "orders",
"field": "createdAt",
"direction": "desc",
"type": "date"
}
}
| Field | Required | Description |
|---|---|---|
source |
no | Dot-path to the array |
field |
no | Field to sort by |
direction |
no | asc (default) | desc |
type |
no |
string (default) |
number | date
|
data.merge_records
Shallow-merge two or more objects. Later sources overwrite earlier on key conflicts.
{
"actionType": "data.merge_records",
"config": {
"sources": ["input", "steps.defaults", "steps.patch.extra"]
}
}
| Field | Required | Description |
|---|---|---|
sources |
yes | Array of dot-paths to merge in order. Later entries overwrite earlier on key conflicts |
"input" = step input;
"steps.stepId" = full step output;
"steps.stepId.field" = nested field.
data.json_path
Extract a value using a JSONPath expression evaluated
against the step's input.
{
"actionType": "data.json_path",
"config": {
"expression": "$.data.items[0].id",
"outputKey": "result"
}
}
| Field | Required | Description |
|---|---|---|
expression |
yes |
JSONPath expression evaluated against the step's
input
|
outputKey |
no |
Wrap result under this key. Default:
"result"
|
Single match returns value directly; multi-match returns
array; no match returns null.
data.extract_columns
Extract specific columns from a 2-D array (e.g. Google
Sheets values). Column letters are Excel-style:
"A", "B", etc.
{
"actionType": "data.extract_columns",
"config": {
"source": "values",
"skipRows": 1,
"columns": ["A", "C", "E"],
"keepEmpty": false
}
}
| Field | Required | Description |
|---|---|---|
source |
no |
Dot-path to the 2D array in input. Default:
"values"
|
skipRows |
no |
Number of header rows to skip. Default:
0
|
columns |
yes |
Excel-style column letters to extract, e.g.
["A", "C", "E"]
|
keepEmpty |
no |
Include rows where all selected cells are empty.
Default: false
|
Output:
{ "items": [[...], [...], ...] } — each
item is an array of cell values in column order.
data.summarize
Wrap a list into a summary document with metadata. Typically
used after logic.foreach_join.
{
"actionType": "data.summarize",
"config": {
"title": "Weekly Report",
"source": "results",
"includeDate": true
}
}
| Field | Required | Description |
|---|---|---|
title |
no | Report title included in output |
source |
no |
Dot-path to the array in input. Default:
"results"
|
includeDate |
no |
Include generatedAt and
generatedDate in output. Default:
true
|
Output:
{ "title", "generatedAt", "generatedDate",
"totalCount", "items" }
data.array_to_map
Convert an array of objects into a keyed map.
{
"actionType": "data.array_to_map",
"config": {
"source": "items",
"keyField": "sku",
"valueField": "price"
}
}
| Field | Required | Description |
|---|---|---|
source |
no |
Dot-path to the array in input. Default:
"items"
|
keyField |
yes | Item field to use as the map key |
valueField |
no | Item field to use as the map value. Omit to use the full item object |
Output:
{ "sku-001": 29.99, "sku-002": 49.99, ... }
data.file
Files
Generate or fetch a binary artifact stored outside workflow
state. Use artifactId in email attachments.
{
"actionType": "data.file",
"config": {
"action": "generate",
"name": "invoice.pdf",
"mimeType": "application/pdf",
"encoding": "base64",
"data": "{{steps.render.pdfBase64}}"
}
}
| Field | Required | Description |
|---|---|---|
action |
no |
"generate" (default) |
"fetch"
|
name |
yes | Artifact filename |
encoding |
no |
"base64" (default) |
"utf8" | "hex"
|
data |
yes (generate) | Content encoded per encoding |
url |
yes (fetch) | HTTPS URL to download |
Output:
{ "artifactId", "name", "mimeType", "size", "expiresAt"
}
data.excel
Files
Generate an .xlsx file either by filling a
namespace template or from scratch. Returns an
artifactId for use with
communication.send_file.
// Fill a template
{
"actionType": "data.excel",
"config": {
"action": "from_template",
"templatePath": "templates/report.xlsx",
"sheet": "Data",
"startRow": 2,
"rows": "{{steps.sql_report.rows}}",
"columns": ["name", "date", "amount"],
"name": "report_{{input.date}}.xlsx"
}
}
// Generate from scratch
{
"actionType": "data.excel",
"config": {
"action": "generate",
"headers": ["Name", "Date", "Amount"],
"rows": "{{steps.sql_report.rows}}",
"columns": ["name", "date", "amount"],
"name": "report.xlsx"
}
}
| Field | Required | Description |
|---|---|---|
action |
no |
from_template (default) or
generate.
|
templatePath |
from_template |
Path inside
workspaces/ns_{id}/files/, e.g.
"templates/report.xlsx".
|
sheet |
no | Sheet name to write data into. Default: first sheet. |
startRow |
no | 1-based row index where data rows begin. Default: 2 (skips header row). |
headers |
generate | Array of column header strings for row 1 (generate mode only). |
rows |
yes |
Array of row objects from a previous step, e.g.
{{steps.sql_report.rows}}.
|
columns |
no |
Ordered list of object keys for column order, e.g.
["name","date","amount"].
|
name |
yes |
Output filename, e.g.
"report_2026-06-07.xlsx".
|
Output: { "artifactId": "..." }. Pass
artifactId to
communication.send_file to deliver the file.
data.parse_csv
Files
Parse a CSV string into an array of objects (with header row) or an array of arrays (without header row).
{
"actionType": "data.parse_csv",
"config": {
"source": "csvContent",
"delimiter": ",",
"hasHeader": true,
"outputKey": "rows"
}
}
| Field | Required | Description |
|---|---|---|
source |
no | Dot-path to the CSV string in the step input. Leave blank to use the input directly. |
delimiter |
no |
Column delimiter character. Default:
",".
|
hasHeader |
no |
When true (default), first row is used
as column names → array of objects. When
false → array of arrays.
|
skipEmpty |
no | Skip blank rows. Default: true. |
maxRows |
no | Error if row count exceeds this limit. Default: 10000. |
outputKey |
no |
Key name for the result array in output. Default:
"rows".
|
Output:
{ "rows": [...] } or
{ [outputKey]: [...] }.
data.to_csv
Files
Convert an array of objects or arrays into a CSV string.
{
"actionType": "data.to_csv",
"config": {
"source": "rows",
"columns": ["name", "email", "amount"],
"delimiter": ",",
"includeHeader": true,
"outputKey": "csv"
}
}
| Field | Required | Description |
|---|---|---|
source |
no | Dot-path to the array in the step input. Leave blank to use the input directly. |
columns |
no |
Ordered list of object keys to include, e.g.
["name","email"]. Default: all keys
from first row.
|
delimiter |
no |
Column delimiter character. Default:
",".
|
includeHeader |
no |
Write column names as first row. Default:
true.
|
outputKey |
no |
Key name for the CSV string in output. Default:
"csv".
|
Output: { "csv": "..." } or
{ [outputKey]: "..." }.
data.db_query
Storage
Run a SELECT query against a database and return matching rows.
{
"actionType": "data.db_query",
"config": {
"connectionSecret": "db/prod",
"driver": "postgres",
"table": "orders",
"where": [["status", "=", "pending"]],
"limit": 100
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name containing the DB connection string. |
driver |
no |
sqlite (default) |
postgres | mysql.
|
table |
yes | Table name to query. |
where |
no |
Array of
[column, operator, value] conditions,
e.g. [["status","=","active"]].
|
limit |
no | Maximum rows to return. |
Output: { "rows": [...] }.
data.db_insert
Storage
Insert a single row into a database table.
{
"actionType": "data.db_insert",
"config": {
"connectionSecret": "db/prod",
"driver": "postgres",
"table": "orders",
"fields": [["status", "pending"], ["created_at", "{{now}}"]]
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name for the connection string. |
driver |
no |
sqlite | postgres |
mysql.
|
table |
yes | Target table name. |
fields |
yes |
Array of [column, value] pairs to
insert.
|
Output: { "lastInsertRowid": ... }.
data.db_update
Storage
Update rows in a database table matching the given conditions.
{
"actionType": "data.db_update",
"config": {
"connectionSecret": "db/prod",
"driver": "postgres",
"table": "orders",
"fields": [["status", "shipped"]],
"where": [["id", "=", "{{input.orderId}}"]]
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name for the connection string. |
driver |
no |
sqlite | postgres |
mysql.
|
table |
yes | Target table name. |
fields |
yes |
Array of [column, value] pairs to set.
|
where |
no |
Array of
[column, operator, value] filter
conditions.
|
Output: { "changes": N } — number of
affected rows.
data.db_delete
Storage
Delete rows from a database table matching the given conditions.
{
"actionType": "data.db_delete",
"config": {
"connectionSecret": "db/prod",
"driver": "postgres",
"table": "sessions",
"where": [["expires_at", "<", "{{now}}"]]
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name for the connection string. |
driver |
no |
sqlite | postgres |
mysql.
|
table |
yes | Target table name. |
where |
no |
Array of
[column, operator, value] filter
conditions.
|
Output: { "changes": N } — number of
deleted rows.
data.db_upsert
Storage
Insert or update a row in a database table based on a conflict key.
{
"actionType": "data.db_upsert",
"config": {
"connectionSecret": "db/prod",
"driver": "sqlite",
"table": "settings",
"fields": [["key", "theme"], ["value", "dark"]],
"conflictKey": "key"
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name for the connection string. |
driver |
no |
sqlite | postgres |
mysql.
|
table |
yes | Target table name. |
fields |
yes | Array of [column, value] pairs. |
conflictKey |
yes | Column name used as the unique conflict key for the upsert. |
Output:
{ "lastInsertRowid": ..., "changes": N }.
data.db_bulk_insert
Storage
Insert multiple rows into a database table in a single transaction. Optionally creates the table if it does not exist.
{
"actionType": "data.db_bulk_insert",
"config": {
"connectionSecret": "db/prod",
"driver": "sqlite",
"table": "report_data",
"rows": "{{steps.parse_csv.rows}}",
"createTableIfNotExists": true,
"onConflict": "ignore"
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name for the connection string. |
driver |
no |
sqlite | postgres |
mysql.
|
table |
yes | Target table name. |
rows |
no | Array of row objects or 2D arrays to insert. |
rowsFormat |
no |
"" for objects (default) or
"spreadsheet" for 2D arrays.
|
createTableIfNotExists |
no |
Infer columns from the first row and create the
table if missing. Default: false.
|
recreateTable |
no |
Drop and recreate the table before insert. Default:
false.
|
addIdColumn |
no |
Add an auto-increment id column when
creating the table. Default: false.
|
onConflict |
no |
fail (default) | ignore |
replace.
|
columnMap |
no |
Map input keys to column names, e.g.
{"firstName":"first_name"}.
|
Output: { "insertedRows": N }.
Communication
data.namespace_store
Storage
Persistent key-value store scoped to the namespace. Lets workflow runs share state across executions — useful for deduplication, counters, cross-workflow flags, and rolling accumulators. Requires base plan or above.
// set
{ "actionType": "data.namespace_store", "config": { "operation": "set", "key": "last_run_id", "value": "{{input.id}}", "ttlSeconds": 86400 } }
// get
{ "actionType": "data.namespace_store", "config": { "operation": "get", "key": "last_run_id" } }
// increment (atomic counter)
{ "actionType": "data.namespace_store", "config": { "operation": "increment", "key": "msg_count", "delta": 1, "outputKey": "count" } }
| Field | Required | Description |
|---|---|---|
operation |
yes |
get / set /
delete / increment /
exists
|
key |
yes |
Key string. Supports
{{expression}} interpolation.
|
value |
set only | Any JSON-serialisable value. Max 64 KB. |
ttlSeconds |
no |
Expire after N seconds (for set and
increment).
|
delta |
no |
Amount to add/subtract for increment.
Default 1. Can be negative.
|
outputKey |
no |
Wrap result under this key instead of
"value".
|
Output: { "value": <result> } or
{ [outputKey]: <result> }.
get returns null when missing or
expired.
communication.send_message
Send a message via email, Telegram, or WhatsApp.
// Email (plain text)
{
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{input.email}}",
"subject": "Your order is confirmed",
"body": "Hello {{input.name}}, your order is ready."
}
}
// Email (HTML + plain text fallback)
{
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{input.email}}",
"subject": "Your order is confirmed",
"body": "Hello {{input.name}}, your order is ready.",
"htmlBody": "{{steps.render_html.output.html}}"
}
}
// Telegram
{
"actionType": "communication.send_message",
"config": {
"channel": "telegram",
"to": "{{env.TELEGRAM_CHAT_ID}}",
"text": "New order: {{input.orderId}}"
}
}
// WhatsApp
{
"actionType": "communication.send_message",
"config": {
"channel": "whatsapp",
"to": "{{input.phone}}",
"text": "Hello {{input.name}}, your booking is confirmed."
}
}
| Channel | Required fields | Notes |
|---|---|---|
email |
to, subject,
body
|
Optional: htmlBody,
templateId,
attachments: [{artifactId}]
|
telegram |
to, text |
Credentials from namespace secrets |
whatsapp |
to, text |
Credentials from namespace secrets. Requires messaging plan. |
When htmlBody is provided, a
multipart/alternative email is sent —
body is used as the plain-text fallback for
clients that do not render HTML. Use
data.render_template to generate
htmlBody from a Handlebars template.
communication.ask_confirm
Telegram
Send a message with Confirm and Cancel inline keyboard
buttons, then park the workflow until the user taps one.
Routes to confirmed or
cancelled output ports accordingly.
{
"actionType": "communication.ask_confirm",
"config": {
"prompt": "Are you sure you want to delete this record?",
"confirmLabel": "Yes, delete",
"cancelLabel": "No, keep it",
"channel": "telegram"
}
}
| Field | Required | Description |
|---|---|---|
prompt |
yes | Message text shown to the user. |
confirmLabel |
no | Text on the confirm button. Default: “Confirm”. |
cancelLabel |
no | Text on the cancel button. Default: “Cancel”. |
confirmValue |
no |
callback_data for the confirm button.
Default: "confirm".
|
signal |
no |
Signal name to wait for. Default:
"menu_choice".
|
to |
no | Chat ID to send to. Defaults to the session chat. |
channel |
no |
Delivery channel. Default:
"telegram".
|
Output ports: confirmed and
cancelled. Connect each to the desired branch.
communication.pick_from_list
Telegram
Build a dynamic inline keyboard from an array in the step input, send it to the user, and park until one item is selected. The selected value is emitted on the output.
{
"actionType": "communication.pick_from_list",
"config": {
"prompt": "Select an employee:",
"itemsPath": "employees.items",
"labelField": "name",
"valueField": "id",
"signal": "user_action",
"channel": "telegram"
}
}
| Field | Required | Description |
|---|---|---|
prompt |
yes | Message text shown above the keyboard. |
itemsPath |
yes |
Dot-path to the array in the step input, e.g.
"employees.items".
|
labelField |
no |
Item field to display as button label. Default:
"name".
|
valueField |
no |
Item field used as the selection value. Default:
"id".
|
signal |
no |
Signal name to wait for. Default:
"user_action".
|
to |
no | Chat ID to send to. Defaults to session chat. |
channel |
no |
Delivery channel. Default:
"telegram".
|
Output: the selected item’s
valueField value.
communication.send_file
Telegram
Send an artifact file through a delivery channel. Typically
used after data.excel or
data.file to deliver a generated file to the
user.
{
"actionType": "communication.send_file",
"config": {
"channel": "telegram",
"artifactId": "{{steps.generate_report.artifactId}}",
"caption": "Your monthly report is attached.",
"to": "{{instance.input.chatId}}"
}
}
| Field | Required | Description |
|---|---|---|
channel |
yes |
Delivery channel. Currently:
telegram.
|
artifactId |
yes |
ID of the artifact to send, e.g.
{{steps.generate_report.artifactId}}.
|
to |
no | Telegram chat ID. Leave empty to send to the session chat. |
caption |
no | Optional file caption (max 1024 characters). |
keyboard_buttons |
no |
Inline keyboard as 2D array:
[[{"text":"OK","callback_data":"ok"}]].
|
botSecret |
no | Secrets store key for the bot token. Use for one-way notifications without a user session. |
Output port: sent.
telegram.register_session
Telegram
Transfer the Telegram session (chatId → instanceId
mapping) to the current workflow instance. Use as the first
step in sub-workflows launched via
flow.run or flow.subprocess so
that communication.send_message and reply
routing work correctly.
{
"actionType": "telegram.register_session",
"config": {
"chatId": "{{input.chatId}}"
}
}
| Field | Required | Description |
|---|---|---|
chatId |
yes |
Telegram chat ID to register for this instance.
Typically "{{input.chatId}}" when the
parent passed chatId through the
flow.run payload.
|
Integrations
integration.http_request
Make an outbound HTTP request. Step input is optionally sent as the JSON body. Supports static Bearer tokens and full OAuth 2.0 client credentials / refresh token flows with automatic token caching.
// Bearer auth
{
"actionType": "integration.http_request",
"config": {
"method": "POST",
"url": "https://api.example.com/orders",
"sendBody": true,
"authType": "bearer",
"authBearerSecret": "my_api_key"
}
}
// OAuth 2.0 — client credentials
{
"actionType": "integration.http_request",
"config": {
"method": "GET",
"url": "https://api.salesforce.com/services/data/v59.0/query",
"authType": "oauth2",
"authGrantType": "client_credentials",
"authTokenUrl": "https://login.salesforce.com/services/oauth2/token",
"authClientIdSecret": "sf/client_id",
"authClientSecretSecret": "sf/client_secret",
"authScope": "api",
"authCacheKey": "salesforce_prod"
}
}
Common fields
| Field | Required | Description |
|---|---|---|
method |
yes |
"GET" | "POST" |
"PUT" | "DELETE". Default:
"POST"
|
url |
yes |
Target URL. Supports
{{...}} expressions
|
timeoutMs |
no |
Abort after this many milliseconds. Default:
30000. Range: 100–120 000
|
sendBody |
no |
Send step input as JSON body (ignored for GET /
DELETE). Default: true
|
authType |
no |
"none" (default) |
"bearer" | "oauth2"
|
Bearer auth —
authType: "bearer"
| Field | Required | Description |
|---|---|---|
authBearerSecret |
yes | SecretsStore key whose value is the static Bearer token |
OAuth 2.0 —
authType: "oauth2"
| Field | Required | Description |
|---|---|---|
authGrantType |
no |
"client_credentials" (default) |
"refresh_token"
|
authTokenUrl |
yes | OAuth 2.0 token endpoint URL |
authClientIdSecret |
yes |
SecretsStore key for the OAuth
client_id
|
authClientSecretSecret |
yes |
SecretsStore key for the OAuth
client_secret
|
authRefreshTokenSecret |
if refresh_token |
SecretsStore key for the refresh token (refresh_token
grant only)
|
authScope |
no | Space-separated OAuth scopes to request |
authCacheKey |
no | Key for caching the token across executions. Omit to always fetch fresh |
authExtraParams |
no |
Extra form fields for the token request, e.g.
{"audience": "https://api.example.com"}
|
Output: { "status": 200, "body": { ... } }.
Non-2xx responses fail the step (retryable on 5xx, 408,
429).
integration.google_sheets
Google Sheets
Read from or write to a Google Sheets spreadsheet via a service account.
{
"actionType": "integration.google_sheets",
"config": {
"credentialSecret": "gsheets_sa_key",
"operation": "read",
"spreadsheetId": "{{env.SHEET_ID}}",
"range": "Sheet1!A1:D100"
}
}
| Field | Required | Description |
|---|---|---|
credentialSecret |
yes | Secret name containing the service account JSON key |
operation |
yes |
read | append |
update | clear |
batchUpdate
|
spreadsheetId |
yes | Google Sheets document ID |
range |
yes |
A1 notation range, e.g. Sheet1!A1:D100
|
integration.script_runner
Execute a script file from the namespace script store. Input is passed via stdin as JSON; stdout is captured as output.
Owner only — this action type is available exclusively to namespace owners. Workflows using it cannot be created or edited by regular users.
{
"actionType": "integration.script_runner",
"config": {
"interpreter": "python",
"scriptPath": "transform_data.py"
}
}
| Field | Required | Description |
|---|---|---|
interpreter |
yes |
"python" | "node" |
"bash"
|
scriptPath |
yes | Path to the script in the namespace script store |
Input is passed to the script via stdin as JSON; captured stdout becomes the step output.
integration.sql
External SQL
Execute a raw SQL statement against a PostgreSQL, MySQL, or SQLite database. Supports SELECT queries, DML (INSERT/UPDATE/DELETE), and multi-statement DDL.
// Query (SELECT)
{
"actionType": "integration.sql",
"config": {
"connectionSecret": "db/prod",
"driver": "postgres",
"operation": "query",
"sql": "SELECT id, name, amount FROM orders WHERE status = $1",
"params": ["pending"],
"outputKey": "rows"
}
}
// Execute (INSERT/UPDATE/DELETE)
{
"actionType": "integration.sql",
"config": {
"connectionSecret": "db/prod",
"driver": "postgres",
"operation": "execute",
"sql": "UPDATE orders SET status = $1 WHERE id = $2",
"params": ["shipped", "{{input.orderId}}"]
}
}
| Field | Required | Description |
|---|---|---|
connectionSecret |
yes | Secret name containing the DB connection string. |
driver |
no |
sqlite (default) |
postgres | mysql.
|
operation |
yes |
query (SELECT) |
execute (INSERT/UPDATE/DELETE) |
exec (multi-statement DDL).
|
sql |
yes |
SQL statement to execute. Use
$1, $2, … for parameterized
values.
|
params |
no |
Array of positional parameter values, e.g.
["active", 42].
|
outputKey |
no |
Key name for results in output. Default:
"rows" for query.
|
timeout |
no | Query timeout in milliseconds. |
Output for query:
{ "rows": [...] }. For execute:
{ "changes": N }.
Finance Finance
Finance actions manage invoices and payments within the built-in billing system. All monetary values are in cents (minor units).
finance.create_invoice
Create a new invoice for a client.
{
"actionType": "finance.create_invoice",
"config": {
"clientId": "{{input.clientId}}",
"amount": "4900",
"currency": "usd",
"items": "[{\"description\":\"Base plan (monthly)\",\"amount\":4900}]",
"dueDate": "2026-07-01"
}
}
| Config field | Required | Description |
|---|---|---|
clientId |
yes | Target client ID (number) |
amount |
yes | Invoice total in cents |
currency |
no | ISO currency code (default: USD) |
items |
yes |
JSON array of
{description, amount} objects
|
dueDate |
no | Due date (YYYY-MM-DD) |
Output:
{ "invoiceId": N, "status": "pending" }
finance.charge
Initiate payment for an invoice. Returns a checkout URL (if external provider) or marks the payment as pending.
{
"actionType": "finance.charge",
"config": {
"invoiceId": "{{steps.create_inv.invoiceId}}",
"returnUrl": "https://portal.example.com/thank-you"
}
}
| Config field | Required | Description |
|---|---|---|
invoiceId |
yes | Invoice to charge |
returnUrl |
no | Redirect URL after checkout |
Output:
{ "paymentId": N, "checkoutUrl": "https://..." }
finance.send_invoice
Send invoice notification via email or Telegram.
{
"actionType": "finance.send_invoice",
"config": {
"invoiceId": "{{steps.create_inv.invoiceId}}",
"channel": "email",
"to": "{{input.email}}"
}
}
| Config field | Required | Description |
|---|---|---|
invoiceId |
yes | Invoice to send |
channel |
yes | email or telegram |
to |
yes | Recipient address or chatId |
Output: { "sent": true }
finance.check_payment_status
Get the current status of a payment.
{
"actionType": "finance.check_payment_status",
"config": {
"paymentId": "{{steps.charge.paymentId}}"
}
}
| Config field | Required | Description |
|---|---|---|
paymentId |
yes | Payment to check |
Output:
{ "status": "completed" | "pending" | "failed",
"paidAt": "..." | null }
finance.refund
Refund a completed payment. Omit amount for a
full refund.
{
"actionType": "finance.refund",
"config": {
"paymentId": "{{steps.charge.paymentId}}",
"amount": "2500"
}
}
| Config field | Required | Description |
|---|---|---|
paymentId |
yes | Payment to refund |
amount |
no | Partial refund in cents (omit for full) |
Output:
{ "refunded": true, "amount": 2500 }
Other
debug.log
Write a log entry and pass input through unchanged. Safe to insert anywhere for debugging.
{
"actionType": "debug.log",
"config": {
"message": "Current amount: {{input.amount}}",
"level": "info"
}
}
| Field | Required | Description |
|---|---|---|
message |
no |
Log message text. Supports
{{...}} expressions
|
level |
no |
"debug" | "info" (default)
| "warn" | "error"
|
level: debug |
info (default) | warn |
error
task.manual
Pause the workflow and wait for a human to complete the
task. The instance stays in running state until
an external caller invokes
POST /api/v1/runtime/step/complete.
{ "actionType": "task.manual", "config": {} }
No configuration required. Complete the task via:
POST /api/v1/runtime/step/complete
{
"instanceId": "42",
"stepId": "review_task",
"outputJson": "{\"approved\": true, \"comment\": \"Looks good\"}"
}
The outputJson becomes this step's output:
{{steps.review_task.approved}} in subsequent
steps. Output port: output.
The action library is actively growing. New action types are added regularly — contact us if you need an integration or capability not listed here.
Examples
Complete workflow definitions ready to copy and use. Pass
the JSON as the definitionJson string value
when saving.
{
"kind": "channelos.workflow",
"schemaVersion": "1.0",
"workflowId": "",
"name": "minimal_example",
"graph": {
"nodes": {
"start": { "id": "start", "label": "Start", "actionType": "flow.start", "config": {} },
"call": {
"id": "call",
"label": "Call API",
"actionType": "integration.http_request",
"config": {
"method": "POST",
"url": "https://httpbin.org/post",
"sendBody": true
}
},
"end": { "id": "end", "label": "End", "actionType": "flow.end", "config": {} }
},
"edges": {
"e1": { "id": "e1", "source": { "nodeId": "start", "portId": "out" }, "target": { "nodeId": "call", "portId": "in" } },
"e2": { "id": "e2", "source": { "nodeId": "call", "portId": "out" }, "target": { "nodeId": "end", "portId": "in" } }
}
}
}
Start input: any JSON — the whole
payload is forwarded as the HTTP POST body. Example:
{ "orderId": "ord-001", "amount": 250 }
{
"kind": "channelos.workflow",
"schemaVersion": "1.0",
"workflowId": "",
"name": "conditional_branch",
"graph": {
"nodes": {
"start": { "id": "start", "label": "Start", "actionType": "flow.start", "config": {} },
"check": {
"id": "check",
"label": "High value?",
"actionType": "logic.condition",
"config": { "expression": "input.amount > 1000" }
},
"priority": {
"id": "priority",
"label": "Notify priority",
"actionType": "communication.send_message",
"config": {
"channel": "telegram",
"to": "{{env.TELEGRAM_CHAT_ID}}",
"text": "High-value order: {{input.orderId}} ({{input.amount}} USD)"
}
},
"standard": {
"id": "standard",
"label": "Standard notify",
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{input.email}}",
"subject": "Order received",
"body": "Your order {{input.orderId}} is being processed."
}
},
"end": { "id": "end", "label": "End", "actionType": "flow.end", "config": {} }
},
"edges": {
"e1": { "id": "e1", "source": { "nodeId": "start", "portId": "out" }, "target": { "nodeId": "check", "portId": "in" } },
"e2": { "id": "e2", "source": { "nodeId": "check", "portId": "true" }, "target": { "nodeId": "priority", "portId": "in" } },
"e3": { "id": "e3", "source": { "nodeId": "check", "portId": "false" }, "target": { "nodeId": "standard", "portId": "in" } },
"e4": { "id": "e4", "source": { "nodeId": "priority", "portId": "out" }, "target": { "nodeId": "end", "portId": "in" } },
"e5": { "id": "e5", "source": { "nodeId": "standard", "portId": "out" }, "target": { "nodeId": "end", "portId": "in" } }
}
}
}
Start input:
{ "orderId": "ord-042", "amount": 1500, "email":
"[email protected]" }. Amount > 1000 routes to the Telegram priority
path; amount ≤ 1000 sends email.
{
"kind": "channelos.workflow",
"schemaVersion": "1.0",
"workflowId": "",
"name": "foreach_email",
"graph": {
"nodes": {
"start": { "id": "start", "label": "Start", "actionType": "flow.start", "config": {} },
"loop": {
"id": "loop",
"label": "For each recipient",
"actionType": "logic.foreach",
"config": { "collection": "recipients" }
},
"send": {
"id": "send",
"label": "Send email",
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{input.item.email}}",
"subject": "{{input.item.subject}}",
"body": "Hello {{input.item.name}}, {{input.item.message}}"
}
},
"loop_end": { "id": "loop_end", "label": "Loop end", "actionType": "logic.foreach_join", "config": {} },
"end": { "id": "end", "label": "End", "actionType": "flow.end", "config": {} }
},
"edges": {
"e1": { "id": "e1", "source": { "nodeId": "start", "portId": "out" }, "target": { "nodeId": "loop", "portId": "in" } },
"e2": { "id": "e2", "source": { "nodeId": "loop", "portId": "out" }, "target": { "nodeId": "send", "portId": "in" } },
"e3": { "id": "e3", "source": { "nodeId": "send", "portId": "out" }, "target": { "nodeId": "loop_end", "portId": "in" } },
"e4": { "id": "e4", "source": { "nodeId": "loop_end", "portId": "out" }, "target": { "nodeId": "end", "portId": "in" } }
}
}
}
Input:
{ "recipients": [{ "email": "...", "name": "...",
"subject": "...", "message": "..." }, ...] }. Each iteration exposes the current element as
input.item.
{
"kind": "channelos.workflow",
"schemaVersion": "1.0",
"workflowId": "",
"name": "approval_flow",
"graph": {
"nodes": {
"start": { "id": "start", "label": "Start", "actionType": "flow.start", "config": {} },
"capture": {
"id": "capture",
"label": "Capture input",
"actionType": "data.transform",
"config": {
"mapping": {
"requesterEmail": "{{input.requesterEmail}}",
"title": "{{input.title}}"
}
}
},
"notify_reviewer": {
"id": "notify_reviewer",
"label": "Notify reviewer",
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{env.REVIEWER_EMAIL}}",
"subject": "Review requested: {{input.title}}",
"body": "Please review the request and complete the task in the portal."
}
},
"review": {
"id": "review",
"label": "Human review",
"actionType": "task.manual",
"config": {}
},
"check": {
"id": "check",
"label": "Approved?",
"actionType": "logic.condition",
"config": { "expression": "input.approved === true" }
},
"on_approved": {
"id": "on_approved",
"label": "Notify approved",
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{steps.capture.requesterEmail}}",
"subject": "Your request was approved",
"body": "Your request has been approved."
}
},
"on_rejected": {
"id": "on_rejected",
"label": "Notify rejected",
"actionType": "communication.send_message",
"config": {
"channel": "email",
"to": "{{steps.capture.requesterEmail}}",
"subject": "Your request was rejected",
"body": "Your request was not approved."
}
},
"end": { "id": "end", "label": "End", "actionType": "flow.end", "config": {} }
},
"edges": {
"e1": { "id": "e1", "source": { "nodeId": "start", "portId": "out" }, "target": { "nodeId": "capture", "portId": "in" } },
"e2": { "id": "e2", "source": { "nodeId": "capture", "portId": "out" }, "target": { "nodeId": "notify_reviewer", "portId": "in" } },
"e3": { "id": "e3", "source": { "nodeId": "notify_reviewer", "portId": "out" }, "target": { "nodeId": "review", "portId": "in" } },
"e4": { "id": "e4", "source": { "nodeId": "review", "portId": "output" }, "target": { "nodeId": "check", "portId": "in" } },
"e5": { "id": "e5", "source": { "nodeId": "check", "portId": "true" }, "target": { "nodeId": "on_approved", "portId": "in" } },
"e6": { "id": "e6", "source": { "nodeId": "check", "portId": "false" }, "target": { "nodeId": "on_rejected", "portId": "in" } },
"e7": { "id": "e7", "source": { "nodeId": "on_approved", "portId": "out" }, "target": { "nodeId": "end", "portId": "in" } },
"e8": { "id": "e8", "source": { "nodeId": "on_rejected", "portId": "out" }, "target": { "nodeId": "end", "portId": "in" } }
}
}
}
Start input:
{ "title": "Budget increase Q3", "requesterEmail":
"[email protected]" }
Complete via API:
POST /api/v1/runtime/step/complete with
{ "instanceId": "...", "stepId": "review",
"outputJson": "{\"approved\": true}" }