How to author a workflow — declaring states + transitions, classifying terminal outcomes with
:terminal-kind, starting processes, firing transitions, and supporting cancellation. For the theoretical background seedoc/concepts/workflow-substrate.md; for the workflow MCP verbs seedoc/api/mcp-verbs.md.
A workflow is three things:
Each terminal state carries a :workflow/terminal-kind declaration — one of :success, :failure, or :cancel. This is what gives consumers a uniform "how did this end?" answer across every workflow.
A worked example: model the lifecycle of a :review/Review — submitted, in-progress, approved, rejected, or cancelled by the author.
┌─────────────┐
│ :submitted │ (initial)
└──────┬──────┘
│ :start-review
▼
┌─────────────┐
│ :in-review │
└─┬─────────┬─┘
:approve│ │:reject
▼ ▼
┌──────────┐ ┌──────────┐
│:approved │ │:rejected │
│(success) │ │(failure) │
└──────────┘ └──────────┘
(cancellable from :submitted or :in-review)
│
▼
┌──────────┐
│:cancelled│
│ (cancel) │
└──────────┘
Workflows are typically declared in EDN under resources/workflows/. Create resources/workflows/review-workflow.edn:
{:workflow/ident :review/document-review-workflow
:workflow/label "Document Review"
:workflow/doc "Lifecycle of a :review/Review from submission to decision"
:workflow/states
[{:workflow/state-name :submitted
:workflow/initial? true
:workflow/doc "Document submitted; awaiting reviewer assignment"}
{:workflow/state-name :in-review
:workflow/doc "Reviewer is actively reviewing"}
{:workflow/state-name :approved
:workflow/terminal? true
:workflow/terminal-kind :success
:workflow/doc "Review complete; document accepted"}
{:workflow/state-name :rejected
:workflow/terminal? true
:workflow/terminal-kind :failure
:workflow/doc "Review complete; document not accepted"}
{:workflow/state-name :cancelled
:workflow/terminal? true
:workflow/terminal-kind :cancel
:workflow/doc "Author or system cancelled the review"}]
:workflow/transitions
[{:workflow/transition-name :start-review
:workflow/from :submitted
:workflow/to :in-review
:workflow/doc "Reviewer accepts the document"}
{:workflow/transition-name :approve
:workflow/from :in-review
:workflow/to :approved}
{:workflow/transition-name :reject
:workflow/from :in-review
:workflow/to :rejected}
;; Cancellation transitions — note both source states are valid origins
{:workflow/transition-name :cancel-from-submitted
:workflow/from :submitted
:workflow/to :cancelled}
{:workflow/transition-name :cancel-from-in-review
:workflow/from :in-review
:workflow/to :cancelled}]}
(require '[sandbar.util.workflow :as wf]
'[clojure.edn :as edn])
(defn load-workflow-def! [path]
(let [data (edn/read-string (slurp path))]
(wf/define-workflow! data)))
(load-workflow-def! "resources/workflows/review-workflow.edn")
Sandbar typically auto-loads every .edn file in resources/workflows/ at startup. Confirm by listing workflows:
(wf/all-workflows)
;; => (... :review/document-review-workflow ...)
(def process
(wf/start-process! :review/document-review-workflow
{:workflow.process/target-entity (:db/id some-review)
:workflow.process/owner (:db/id some-user)}))
(:workflow/current-state process)
;; => :submitted
;; Reviewer accepts the document
(def p2 (wf/transition! process :start-review))
(:workflow/current-state p2)
;; => :in-review
;; Reviewer approves
(def p3 (wf/transition! p2 :approve))
(:workflow/current-state p3)
;; => :approved
(:workflow/terminal-kind p3)
;; => :success
If a transition isn't valid from the current state, transition! throws:
(wf/transition! p3 :approve)
;; throws — :approve has no valid transition from :approved (terminal)
(def p (wf/start-process! :review/document-review-workflow {...}))
;; Check if cancellation is valid from the current state
(wf/can-cancel? p)
;; => true (because :cancel-from-submitted is a transition from :submitted)
(def p-cancelled (wf/cancel-process! p))
(:workflow/current-state p-cancelled)
;; => :cancelled
(:workflow/terminal-kind p-cancelled)
;; => :cancel
cancel-process! looks for a transition from the current state to a terminal-:cancel state. If none exists, the cancel is refused. This is the F-B-002 design (captured in decisions/sandbar_workflow_cancellation_modeled_as_terminal_kind_on_states_2026_05_12) — cancellation is a property of the state design, not per-tool code.
Every transition records a :workflow/Transition-Record:
(wf/process-history process)
;; => [{:workflow/transition-via :start-review
;; :workflow/transition-at #inst "..."
;; :workflow/from-state :submitted
;; :workflow/to-state :in-review}
;; {:workflow/transition-via :approve
;; ...}]
History is queryable as data — no separate audit log. See writing-a-clojure-client.md for the API.
Wrap per-entity validation in a workflow whose terminal-kind classifies the run:
:starting → :running → :results-pending → :complete
├── :success (all valid)
├── :failure (any invalid)
└── :cancel (consumer aborted)
This is the pattern Sandbar uses internally for sandbar.validation.* MCP verbs. Per-instance validation runs as the :running → :results-pending transition's action; the eventual terminal state is determined by the accumulated errors.
Any workflow process is implicitly an MCP Task — task-id IS :db/id. No additional registration needed. When an MCP client calls tasks/get task-12345, the response is computed from the corresponding process. See doc/concepts/workflow-substrate.md.
If cancellation is valid from several states, declare a separate transition for each source:
{:workflow/transition-name :cancel-from-submitted
:workflow/from :submitted
:workflow/to :cancelled}
{:workflow/transition-name :cancel-from-in-review
:workflow/from :in-review
:workflow/to :cancelled}
This is more verbose than a wildcard "cancel from anywhere," but it lets can-cancel? answer state-by-state — and it makes the cancel-able states visible in the workflow definition.
If cancellation is not valid for a workflow, simply declare no terminal-:cancel state. can-cancel? returns false; cancel-process! refuses.
Transitions can carry actions — Clojure functions invoked during the transition:
{:workflow/transition-name :approve
:workflow/from :in-review
:workflow/to :approved
:workflow/action 'my.namespace/approve-review-action}
The action receives the process entity and any transition arguments; its return value is recorded in the transition record. Actions should be idempotent — transitions may be retried if the surrounding transaction fails.
Workflows are pure data; testing them is straightforward:
(ns review.workflow-test
(:require [clojure.test :refer :all]
[sandbar.test.fixture :as fixture]
[sandbar.util.workflow :as wf]))
(use-fixtures :each fixture/with-memdb)
(deftest happy-path
(let [p (wf/start-process! :review/document-review-workflow {})
p2 (wf/transition! p :start-review)
p3 (wf/transition! p2 :approve)]
(is (= :approved (:workflow/current-state p3)))
(is (= :success (:workflow/terminal-kind p3)))))
(deftest cancel-from-submitted
(let [p (wf/start-process! :review/document-review-workflow {})]
(is (wf/can-cancel? p))
(let [p2 (wf/cancel-process! p)]
(is (= :cancelled (:workflow/current-state p2)))
(is (= :cancel (:workflow/terminal-kind p2))))))
(deftest no-cancel-from-terminal
(let [p (-> (wf/start-process! :review/document-review-workflow {})
(wf/transition! :start-review)
(wf/transition! :approve))]
(is (not (wf/can-cancel? p)))))
(deftest invalid-transition-throws
(let [p (wf/start-process! :review/document-review-workflow {})]
(is (thrown? Exception (wf/transition! p :approve)))))
Forgetting :workflow/terminal-kind on a terminal state. The schema validator catches this (per the F-B-002 ADR), but the error message points at the workflow's EDN, not the runtime call. Always declare :terminal-kind when :terminal? is true.
Using string heuristics for cancellation. Pre-F-B-002, cancellation was decided by substring-matching "cancel" in state idents. This was replaced by explicit :terminal-kind :cancel. Don't reintroduce string heuristics — let the substrate enforce the semantics.
Treating workflow processes as ephemeral. Processes are first-class entities. They survive across server restarts, can be queried by Datalog, and accumulate history. If you want truly ephemeral state, use Clojure atoms or refs, not workflows.
Mixing application state into workflow state. Workflow state is "where in the FSM is this process?" Application state ("what data is being reviewed?") lives in the application entities, referenced by the process's :target-entity or equivalent slot. Conflating the two makes both messier.
Designing workflows around code rather than around the consumer's mental model. The terminal-kind classification exists for the consumer. Design the state space around how a consumer (CLI, AI, dashboard) should observe and intervene; let the implementation conform.
doc/concepts/workflow-substrate.md — the theoretical foundationdoc/api/mcp-verbs.md — full MCP verb reference for workflowswriting-a-clojure-client.md — Clojure API patternsdefining-new-classes.md — classes whose lifecycle this workflow modelsCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |