kdl language
KDL basics
KDL is a config language that reads like JSON without the quotes. wflow uses a small subset: one top-level workflow block with a title, an optional trigger, an optional vars block, and a list of step nodes that run in order.
The minimum
workflow "Hello" {
notify "hi"
}That's a complete workflow. A title, a single step, no trigger (so it only runs on demand from the GUI or via wflow run hello).
The full shape
// Comments are double-slash, anywhere in the file.
workflow "Focus Mode" {
// Optional editorial copy. Shows on the workflow detail page
// and on cards in /browse.
subtitle "Silences everything for 25 minutes."
// Optional trigger. At most one chord per workflow.
trigger {
chord "super+alt+f"
}
// Steps. Run top to bottom.
shell "swaync-client -d on"
notify "Focus engaged"
wait "25m"
shell "swaync-client -d off"
notify "Done"
}Step shapes
Steps can take positional args, named props, both, or a body block.
Positional args
type "hello"
key Return
wait 2000
notify "Done"Named props
focus window="Slack"
shell "git push" timeout-ms=5000
notify "Pushed" body="branch went up clean"Mixed
shell "echo hi" timeout-ms=2000
type "/standup" delay-ms=80Block-shaped (control flow)
Three step kinds take a body of inner steps: repeat, when, unless. Detail on the control-flow page.
repeat 3 {
key Tab
wait 100
}
when window="Firefox" {
key ctrl+l
}Strings, numbers, durations
Strings can be double-quoted or, for simple identifiers, bareword (KDL v2). focus "Slack" and focus Slack mean the same thing. Quote anything with spaces or special characters. Use \\" to escape an inner double-quote.
Numbers are bare integers. wait 500 is half a second (wait takes milliseconds). repeat 3 means three iterations.
Durations in wait and wait-window timeout can also be string-shaped: wait "1.5s", wait "250ms", wait "2m". The integer form is plain milliseconds.
Variables
Workflows can declare a top-level vars block for values you want to reference in multiple steps:
workflow "Slack standup" {
vars {
channel "#standup"
repo "~/projects/wflow"
}
focus Slack
key ctrl+k
type "{{channel}}"
key Return
type "cd {{repo}} && git status"
}Variables are interpolated with {{name}} anywhere a string arg appears. Override them at the CLI with wflow run my-workflow --var channel=#oncall.
Capturing shell output
Run a shell command, name its stdout, then reference it as a variable in later steps:
shell "git rev-parse --short HEAD" as=sha
notify "deployed" body="commit {{sha}}"The as= prop captures stdout into the named variable. There's no implicit $stdout — every capture is explicit, so you can run several shell steps and keep their outputs distinct.
Imports across files
Pull in a fragment file (a step list with no workflow wrapper) and call it as a sub-step:
workflow "Daily standup" {
imports {
standup-message "./lib/standup-message.kdl"
}
focus Slack
key ctrl+k
type "#standup"
key Return
use standup-message
}Imports resolve at decode time, relative to the parent workflow's directory. Fragment files contain only step nodes, no workflow wrapper. Useful for sharing a chunk of steps across several workflows without copy-paste.
What's not in the language
- No functions or macros.
repeatis the only repetition primitive. - No arithmetic conditionals. There's no
if value > 5; that's shell territory. Workflow conditionals branch on window presence, file presence, or env-var equality (see control flow). - No data structures. The state model is named variables. List comprehensions, maps, JSON parsing — all live behind a single
shellstep.
These are deliberate constraints. wflow is for keystroke-level automation, not general-purpose scripting. If your workflow wants loops over data, write a shell script and call it from a single shell step.