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.
SourceSource tour (Docco)www.getcolors.ai
1. Philosophy
- Data in, data out. All state crossing step boundaries is one immutable map,
opts. A step reads it and returns a new one. - The graph is data, routing is a function. A static
wire-fndeclares the happy path; an optionalnext-fndecides at run time where each branch goes. - Unix-style outcomes. Success and failure travel in
optsas an exit code, not as exceptions. - Cross-cutting behaviour is advice. Dry-run, progress, validation, retries and locks wrap steps without touching their code or the wiring.
- Desired state is a file; secrets are not. A YAML file describes what should exist. Credentials arrive through
COLORS_PAR_*environment variables. - Steps own side effects. A step calls a CLI (
gcloud,doctl,oci,uname) or an HTTP API directly. green provides the engine, not the integrations. - Pure construction. Every constructor and advice function returns a new workflow value; workflows are safe to share and branch.
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).
| Key | Set by | Meaning |
|---|---|---|
:green/exit | steps, engine | 0 = success (missing means 0), >0 = failure. |
:green/err | steps, engine | Human-readable error text. |
:green/trace | engine | Stack trace when a step threw. |
:green/step | engine | Name of the step about to run (visible to advice). |
:green/branches | engine | Vector 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)))))
- A step fails by returning
:green/exit> 0. A branch stops after a failing step unlessnext-fnroutes it. - A thrown exception is caught and converted to
:green/exit 1(or the:green/exitin itsex-data) with:green/errand:green/trace. A non-map return is also a failure. - A successful step may omit
:green/exit; the engine normalises it to 0. wf/failed?is the predicate for a positive exit, for steps composed of sub-steps.
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:
- keeps live branches and finished branches, grouping live ones by step;
- 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;
- runs ready work concurrently;
- on zero successors finishes a branch, on one continues, on several forks;
- 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); - 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;
- 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.
| how | Behaviour (FUNCTION = advice, OLDFUN = inner chain) | Typical use |
|---|---|---|
:around | (FUNCTION OLDFUN opts) | dry-run, retry, timing, locks |
:before | FUNCTION for effect, then OLDFUN | setup, prerequisites |
:after | OLDFUN, then FUNCTION for effect; returns OLDFUN's value | audit, metrics, cleanup |
:override | FUNCTION only | stubs, replacements |
:before-while | OLDFUN only if FUNCTION is truthy | precondition gates |
:before-until | OLDFUN only if FUNCTION is falsy | fast paths, no-ops |
:after-while | FUNCTION only if OLDFUN succeeded (:green/exit 0) | success-only follow-ups |
:after-until | FUNCTION only if OLDFUN failed | recovery, fallback |
:filter-args | (OLDFUN (FUNCTION opts)) | normalise or scope input |
:filter-return | (FUNCTION (OLDFUN opts)) | normalise or enrich output |
- Ordering. Per-step and all-steps advice stack in strict add order: the most recently added is outermost.
{:depth n}(−100..100, default 0) overrides that: lower is further outside. - Scope. Registries are values inside the workflow, never process-global.
- Inheritance. Through
wf/stepembeds, an enclosing run's advice applies to the embedded workflow's steps of the same name at any depth. At equal depth ancestor advice is outside child advice; an ancestor entry with the same id replaces the child's. - Inspection.
(wf/advice-plan wf-or-chain step)returns the composed stack, outermost first, with:scope(:step/:all) and:level.
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.
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
| Namespace | Public vars |
|---|---|
green.workflow | workflow, run, step, failed?, advice-add, advice-remove, advice-add-all, advice-remove-all, advice-plan |
green.advice | hows, add, remove-id, add-global, remove-global-id, merge-entries, merge-registry, ordered, compose |
green.cli | read-state, read-pars, par-name |
Dependencies: Clojure and org.yamlstar/yamlstar (exactly 0.1.17). The source tour walks every form.