Workflow SDK · Clojure / Babashka

greenspecification · 2026-10-31

Coding agents write deployment code directly. Terraform and Ansible existed because hand-writing cloud automation was too slow and too fragile; an agent now writes the steps that call cloud CLIs and APIs itself. The colors are the foundation for that code: three workflow SDKs — green (Clojure), red (TypeScript) and blue (Python) — sharing one model.

DAGs, subworkflows and advice form the reusable core. Applications own their CLI, validation, planning, reporting and persistent recovery journal. Configuration helpers parse YAML/EDN and overlay environment values explicitly.

green is the canonical colour. Build your own package on it, or start from PocketDeploy, the starter package built on blue. green has no OpenTofu or Ansible integration any more: OpenTofu and Ansible support was removed on 2026-10-29, and steps call the tools they need directly.

1. Philosophy

2. The opts contract

A workflow run threads a single map. Keys under :green/* are reserved for the engine; everything else uses the package's own namespace (:hello/kernel) or flat desired-state keys (:name, :do-token).

KeySet byMeaning
:green/exitsteps, engine0 = success (missing means 0), >0 = failure.
:green/errsteps, engineHuman-readable error text.
:green/traceengineStack trace when a step threw.
:green/stepengineName of the step about to run (visible to advice).
:green/branchesengineVector of branch results given to a join step, or attached to a collapsed fork.

3. Steps and errors

A step is a function opts -> opts, named in the graph by a qualified keyword.

(defn uname-step [opts]
  (let [{:keys [exit out err]} (shell/sh "uname" "-s")]
    (if (zero? exit)
      (assoc opts :hello/kernel (str/trim out))
      (assoc opts :green/exit exit :green/err (str "uname failed: " err)))))

4. Workflows: wire-fn and next-fn

(wf/workflow {:start   :hello/start   ; required
              :end     nil            ; optional slice boundary: runs, then stops
              :wire-fn wire-fn        ; required
              :next-fn nil})          ; optional

(wire-fn step run-opts) -> [step-fn & next-steps] is the static graph for a run. run-opts is the initial opts of the run, constant for its duration, so the graph may depend on stable inputs such as :hello/event:

(defn wire-fn [step run-opts]
  (case [(:hello/event run-opts) step]
    [:create :hello/start]  [start-step :hello/uname :hello/write]
    [:create :hello/uname]  [uname-step :hello/check]
    [:create :hello/write]  [write-step :hello/check]
    [:create :hello/check]  [check-step]
    [:delete :hello/start]  [start-step :hello/remove]
    [:delete :hello/remove] [remove-step]))

(next-fn step default-next opts) -> [[next-step opts] ...] is optional dynamic routing: fan out one branch per item, branch on a result, or route failures. default-next is the wire-fn's successor list. Without a next-fn, a failed branch stops and a successful one follows default-next with the same opts.

(defn next-fn [step default-next opts]
  (cond
    (wf/failed? opts) []
    (= step :app/start) (for [n (:nodes opts)] [:app/node (assoc opts :app/node n)])
    :else (map (fn [s] [s opts]) default-next)))

(wf/run workflow opts) runs from :start and returns the final opts; its :green/exit is the workflow's exit code.

5. Parallelism and joins

Several successors fork: each branch runs in its own future. The scheduler:

  1. keeps live branches and finished branches, grouping live ones by step;
  2. runs a step only when no other live branch can still reach it through the run's static wire-fn edges, so longer branches arrive before a join runs;
  3. runs ready work concurrently;
  4. on zero successors finishes a branch, on one continues, on several forks;
  5. runs a step reached by branches of different origins once, as a join, with the fork-point opts plus :green/branches (each branch's result);
  6. collapses a fork in which a branch failed: running siblings finish their current step, no further fork work starts, the join is skipped, and the result carries the worst exit plus all branch results;
  7. returns the single terminal result, or with several terminals the first failure, otherwise the last success.

A join step typically merges its branches: (apply merge opts (:green/branches opts)).

6. Composition

(wf/step sub-workflow {:in-fn f :out-fn g}) turns a whole workflow into an ordinary step: wire it, advise it, fan it out. :in-fn shapes the opts entering the sub-workflow; :out-fn (fn [opts sub-result] ...) merges the result back (default: the sub-result is the step's result). With the default output adapter its :green/exit propagates. A custom :out-fn must preserve or explicitly handle the child failure; replacing its result can discard it. A custom :in-fn should carry ambient keys such as :hello/event and :hello/dry-run if the sub-workflow needs them.

7. Advice

Emacs nadvice-style wrappers, attached by id. (wf/advice-add wf step how id f [props]) targets one step; (wf/advice-add-all wf how id f [props]) every step. advice-remove and advice-remove-all remove by id; re-adding an id replaces it.

howBehaviour (FUNCTION = advice, OLDFUN = inner chain)Typical use
:around(FUNCTION OLDFUN opts)dry-run, retry, timing, locks
:beforeFUNCTION for effect, then OLDFUNsetup, prerequisites
:afterOLDFUN, then FUNCTION for effect; returns OLDFUN's valueaudit, metrics, cleanup
:overrideFUNCTION onlystubs, replacements
:before-whileOLDFUN only if FUNCTION is truthyprecondition gates
:before-untilOLDFUN only if FUNCTION is falsyfast paths, no-ops
:after-whileFUNCTION only if OLDFUN succeeded (:green/exit 0)success-only follow-ups
:after-untilFUNCTION only if OLDFUN failedrecovery, fallback
:filter-args(OLDFUN (FUNCTION opts))normalise or scope input
:filter-return(FUNCTION (OLDFUN opts))normalise or enrich output

8. YAML desired state and COLORS_PAR_*

cli/read-state reads .yml/.yaml with yamlstar (YAML 1.2 core schema, pinned exactly so the red and blue ports read identical values) and anything else as EDN. Map keys become keywords at every depth. Under the core schema no, yes and on stay strings.

# green.yml — non-secret by rule
name: world
greeting-file: work/hello.txt

cli/read-pars overlays every COLORS_PAR_<UPPER_SNAKE> environment variable onto the matching flat key: COLORS_PAR_DO_TOKEN → :do-token. Values are coerced to the type of the value they replace (booleans and integers), and applying the overlay twice changes nothing. cli/par-name maps a key back to its variable name. The prefix is shared by all three colours.

Secrets never go in the desired-state file. Keys are kebab-case; credentials are supplied only as COLORS_PAR_* variables.

The green.cli namespace retains its name for these three helpers; it no longer provides argument parsing or process execution. Applications choose file paths, defaults and validation themselves. Engine slicing remains available through :start and :end.

Green is not on Clojars. External packages use an explicit published git SHA:

io.github.getcolors/green {:git/url "https://github.com/getcolors/green.git"
                           :git/sha "REPLACE_WITH_COMMIT_SHA"}

9. The example

examples/hello is a complete package in three files: the green launcher (holding the workflow), green.yml, and test/hello/launcher_test.clj. It runs offline and writes only the configured greeting file. Tests use temporary directories. Its CLI, validation, reporting and dry-run advice are application code.

:hello/start (application validation)
   ├─ :hello/uname      uname -s
   └─ :hello/artifact   subworkflow: write → verify
        ↘ ↙
   :hello/check         join and report

The parent installs reporting advice for every step, including the artifact subworkflow's internal steps. It also installs application-defined skip advice on write, verify, check and remove. Step names are namespaced; inherited advice matches those names at any depth.

(def artifact-workflow
  (wf/workflow {:start :hello/write
                :wire-fn (fn [step _]
                           (case step
                             :hello/write [write-step :hello/verify]
                             :hello/verify [verify-step]))}))

;; Wire (wf/step artifact-workflow) like any other step.
(defn report-step [inner opts]
  (let [result (inner opts)]
    (locking *out* (println "completed" (:green/step opts)))
    result))
(wf/advice-add-all workflow :around :hello/report report-step)
cd examples/hello
./green create --dry-run
./green create                         # hello, world (Linux)
COLORS_PAR_NAME=agent ./green create   # hello, agent (Linux)
./green delete

10. Testing

bb test                # Babashka
clojure -X:test        # JVM (cognitect test-runner)
clojure -T:build jar   # target/green-0.1.0.jar
bb docco               # regenerate docco.html

Steps are plain functions, so packages test workflows in-process with wf/run, using :override advice to stub side effects. The example's test drives the real launcher as a subprocess (needs bb on PATH). CI runs both suites and builds the jar.

11. API index

NamespacePublic vars
green.workflowworkflow, run, step, failed?, advice-add, advice-remove, advice-add-all, advice-remove-all, advice-plan
green.advicehows, add, remove-id, add-global, remove-global-id, merge-entries, merge-registry, ordered, compose
green.cliread-state, read-pars, par-name

Dependencies: Clojure and org.yamlstar/yamlstar (exactly 0.1.17). The source tour walks every form.