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=80

Block-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. repeat is 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 shell step.

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.

Next

Triggers →