This document defines the normalized workflow IR for deterministic workflow steps.
The workflow IR is the canonical runtime execution model for authored workflow
files compiled from doc/workflow-grammar.md.
It is intentionally not a user-facing authoring grammar. It is the normalized execution boundary used by runtime execution.
The workflow IR exists to make the runtime independent of authored syntax.
It gives execution one canonical model for:
The runtime should execute IR, not raw authored workflow documents.
The IR should have these properties:
:type discriminationA normalized workflow IR is a map with ordered steps.
Illustrative top-level shape:
{:version :workflow-ir/v1
:steps [ir-step+]}
The normalized IR boundary requires at least one step. Empty workflows are invalid IR and should be rejected before execution.
This IR is the compiled execution model, not an authored workflow surface. It is intentionally close to the workflow grammar while remaining free to carry runtime-oriented normalization detail.
The ordered :steps vector is the canonical authored/program order.
The runtime may derive a name index or graph index as needed, but those are execution-time conveniences rather than authored meaning.
The IR has three step execution forms:
Each step has exactly one execution :type.
Illustrative shape:
{:name "discover"
:type :invoke
...}
All step forms share these conceptual fields:
:name:type:outputs:yields:judge:on:max-iterationsThe IR should validate execution-specific fields according to step type.
Important alignment rule:
:invoke, :session, or :delegateThis keeps authored syntax compact while giving runtime one explicit normalized place for execution payload.
An invoke step executes a deterministic operation.
Illustrative shape:
{:name "discover"
:type :invoke
:invoke {:operation "github/search-issues-by-label"
:args {:repo {:from :workflow-input :path [:repo]}
:labels {:from :workflow-input :path [:labels]}
:state "open"}}
:outputs {:data {:source :invoke/data}
:summary {:source :invoke/summary}
:result {:source :invoke/result}}
:yields {:type :data
:data :data}}
:operation is a canonical runtime operation id:args is a fully normalized named-argument map:data:summary:resultA session step constructs and runs a child session inline.
Illustrative shape:
{:name "report"
:type :session
:session {:model "gpt-5.4"
:tools ["read" "bash"]
:skills ["issue-feature-triage"]
:contributions [{:type :source
:from :workflow-original}
{:type :template
:text "Review these issues:\n\n{{issues}}"
:vars {"issues" {:from {:step "discover" :output :data}
:path [:issues]}}}]}
:outputs {:final-llm-reply {:source :session/final-llm-reply}
:transcript {:source :session/transcript}
:result {:source :session/result}}
:yields {:type :text
:text :final-llm-reply}}
:session contains the effective child-session construction data:contributions are ordered and preserved as authored:final-llm-reply:outputs with :source :session/structured-outputA session step may declare at most one structured output entry under its
step-local :outputs map. The output key is the logical machine-facing value
that downstream references address. Authors who need multiple machine-facing
fields should model them as fields inside that one structured value.
Illustrative normalized shape:
{:name "classify-reproduction"
:type :session
:session {:contributions [...]}
:outputs {:classification
{:source :session/structured-output
:mode :structured
:schema-id :psi.workflow/bug-reproduction-classification
:schema-version 1
:schema [:map
[:status [:enum :reproducible :not-reproducible :unclear]]
[:summary :string]
[:evidence [:vector :string]]
[:commands-run [:vector :string]]
[:next-action [:enum :request-more-info :handoff-to-fix :stop]]]}}
:yields {:type :data
:data :classification}}
Session structured outputs are optional. If omitted, the session step remains a
text-producing step and should expose :final-llm-reply when downstream text
consumption is needed.
A delegate step invokes another workflow through an explicit workflow boundary.
Illustrative shape:
{:name "report-call"
:type :delegate
:delegate {:target "builder"
:prompt-string {:type :template
:text "Review these issues:\n\n{{issues}}"
:vars {"issues" {:from {:step "discover" :output :data}
:path [:issues]}}}
:context [{:type :source
:from :workflow-original}
{:type :source
:from {:step "discover" :output :data}
:path [:issues]}]}
:yields {:type :delegated}}
:target resolves to a named workflow definition:prompt-string must render to a final string before invocation:context is ordered forwarded material and is optional:workflow-input becomes the rendered prompt string:workflow-original is rebound for the delegated invocation{:step ... :output ...} refs against the delegate step itselfThe IR separates execution from output surfaces.
Each step may expose step-local outputs by logical output key.
Illustrative common output keys:
:data, :summary, :result:final-llm-reply, :transcript, :result, plus any declared structured output keys:handoff:outputs map for transition evaluation onlyThe :outputs map should describe what logical output keys exist for a step or
judge and, at minimum, give runtime a canonical local meaning for each key.
The exact internal value of each :outputs entry may evolve, but the key-space should be stable for runtime reference and validation.
If a local :yields form names an output key (for example {:type :text :text :final-llm-reply} or {:type :data :data :data}), that output key must be declared in the same step's :outputs map at the normalized IR boundary.
A structured output spec is an output entry with a structured source and schema contract. The normalized IR accepts these sources for this slice:
:session/structured-output on session-step :outputs:judge/structured-output on LLM-judge :outputsNormalized structured output spec shape:
{:source structured-output-source
:mode :structured
:schema-id schema-id
:schema-version schema-version
:schema malli-schema
:json-schema json-schema-map
:strategy-preference :provider-native
:fallback :prompted-json
:require-provider-native? false
:on-invalid? invalid-policy}
Invalid structured-output handling depends on the structured output source. For
session-step structured outputs, the first implementation's minimum invalid
policy is fail-fast: if :on-invalid is omitted, runtime treats it as
{:action :fail-fast}. For LLM judge structured outputs, invalid generated
structured output is retried by default up to the built-in judge retry limit using
judge retry feedback; each retry preserves the original structured-output
options/schema. Unsupported structured output still fails immediately with
:unsupported-structured-output rather than retrying. Explicit :on-invalid
retry/repair policy beyond this built-in judge retry behavior may be added only
when proven by tests.
Cardinality is constrained in the normalized IR: a session step must not contain
more than one :source :session/structured-output entry, and an LLM judge must
not contain more than one :source :judge/structured-output entry. Compiler or
IR validation should reject multiple structured entries clearly. One raw
model/judge response maps to one structured envelope for the one declared key.
Structured workflow outputs use two schema contracts. :schema is the Malli
contract used by workflow runtime for local coercion and validation. :json-schema
is the explicit provider/request contract passed under provider-neutral
:structured-output options to turn execution; workflow runtime does not infer
JSON Schema from Malli. Omitted :strategy-preference defaults to
:provider-native, omitted :fallback defaults to :prompted-json, and
:require-provider-native? true forbids fallback. :fallback :none also forbids
fallback. If a structured spec opts into provider-native request shaping but has
no :json-schema, execution fails with :missing-json-schema before generation.
If fallback is forbidden and the resolved model/transport cannot provide native
structured output, execution fails with :unsupported-structured-output rather
than silently degrading to prose.
For :prompted-json, the AI adapter injects schema-guided JSON-only
instructions into the outbound provider request, and the workflow runtime parses
the returned text as a single JSON value matching the declared JSON Schema. For
:provider-native, the provider is asked for a single schema-constrained JSON
value. Scalar, array, object, boolean, number, string, and null values are all
valid when the schema allows them. Sibling JSON fields are not promoted into
additional output keys; use a map/object schema plus downstream :path
references instead.
Execution records the resolved value behind a structured output key as a
canonical envelope. Downstream references must use only the validated
:structured-output :value when status is :valid.
Valid example:
{:raw-output "..."
:structured-output
{:mode :structured
:schema-id :psi.workflow/judge-review-result
:schema-version 1
:strategy :provider-native
:native-mechanism :openai/chat-completions-json-schema-response-format
:source :openai/message-json
:payload {"decision" "needs-work"
"issues" []
"confidence" 0.84}
:raw-payload "... raw provider diagnostic payload when available ..."
:status :valid
:value {:decision :needs-work
:issues []
:confidence 0.84}}}
Invalid example:
{:raw-output "..."
:structured-output
{:mode :structured
:schema-id :psi.workflow/judge-review-result
:schema-version 1
:strategy :prompted-json
:status :invalid
:errors [{:message "missing required key"
:path [:decision]}]
:parsed-value {"issues" []}}}
The strategy field is observable runtime metadata. The first slice should record at least one of:
:provider-native — the provider/API accepted a structured-output schema or mode directly:prompted-json — the AI adapter injected a JSON-only/schema instruction, while workflow runtime parsed the result, coerced it to Malli-domain data, and validated it:repair-parse — the runtime performed an explicit repair parse attempt:unsupported — the runtime could not reasonably request the structured modeFor :prompted-json, the adapter-owned request shaping is limited to prompting
for a single JSON value that matches the declared schema; it does not make the
provider response trusted workflow data. Raw model text remains in
:raw-output; workflow runtime parses JSON and schema-guides it into
Malli-domain values before validation. Object keys may become keywords when the
schema expects map keys, and enum strings may become keyword enum values when
the declared enum contains the corresponding keyword. Non-object JSON values are
preserved and validated directly when the schema allows them. Coercion, parse,
or validation failure records :status :invalid, :errors, and
:parsed-value when a parsed value exists; it must not expose :value.
Reusable schemas are owned by workflow-runtime code. The first standard reusable
schema should live in psi.workflow-runtime.structured-output-schemas with id
:psi.workflow/judge-review-result and version 1. Runtime/docs/tests should
refer to the id/version pair, and known reusable schema declarations should match
the exported Malli schema for that id/version.
The IR distinguishes:
:output:yieldsThe first-cut yielded-value union is:
{:type :data
:data output-key}
{:type :text
:text output-key}
{:type :error
:reason keyword
:message string
:details? map}
For delegate steps, the normal behavior is compositional delegation:
{:type :delegated}
Meaning:
This avoids redundantly restating the delegated workflow's yield form at every delegating callsite.
When omitted in authored input, compiler-side normalization must supply defaults before the runtime-owned IR validation boundary:
{:type :data :data :data}{:type :text :text :final-llm-reply}{:type :delegated}The IR validator treats missing :yields as invalid normalized IR rather than filling defaults locally.
This aligns with the target grammar's preferred defaults:
:data:final-llm-replyControl flow is orthogonal to execution form.
The IR uses:
:judge:on:max-iterationsIllustrative shape:
{:judge {...}
:on {"APPROVED" {:goto :done}
"REVISE" {:goto "build" :max-iterations 3}}
:max-iterations 5}
:on maps that outcome to a transition directive:on when :judge is present, and requires :judge when :on is present:done, the parent step's yielded value becomes the workflow resultThe IR should normalize judge execution mode explicitly.
Current executed runtime support:
:type :llm:type :invokecomponents/agent-session/src/psi/agent_session/workflow_judge.clj executes both
prompt/session-based LLM judges and deterministic invoke judges. LLM judges create
a judge child session and route from the judge response. Invoke judges resolve
:invoke :args with the shared workflow source-resolution path, invoke the named
deterministic operation through the deterministic operation registry, and route
from the operation's returned data.
Therefore:
:judge {:type :llm ...} is part of the current executed IR contract:judge {:type :invoke :invoke {:operation ... :args ...}} is part of the
current executed IR contract and is used by built-in review workflows for
deterministic PASS_STATUS and constant follow-up routingIllustrative shape:
{:type :llm
:session {:model "gpt-5.4"
:contributions [...]}
:projection {:type :tail
:turns 4
:tool-output false}}
An LLM judge may declare judge-local structured outputs. The :outputs map is
local to the judge result, not to the parent step's ordinary text outputs.
Illustrative normalized structured judge shape:
{:type :llm
:session {:model "gpt-5.4"
:contributions [...]}
:outputs {:review
{:source :judge/structured-output
:mode :structured
:schema-id :psi.workflow/judge-review-result
:schema-version 1
:schema [:map
[:decision [:enum :clear :needs-work :unclear]]
[:issues
[:vector
[:map
[:severity [:enum :blocking :minor]]
[:kind [:enum :ambiguity :inconsistency :missing-acceptance :scope-drift]]
[:description :string]
[:evidence :string]
[:suggested-change :string]]]]
[:confidence [:double {:min 0.0 :max 1.0}]]]}}
:projection {:type :tail
:turns 4
:tool-output false}}
Judge structured outputs are intended for judge routing, retry decisions, and
review-loop control data. They do not replace the parent step's yielded value; a
judge routes while the parent step still yields according to its own :yields
form. Judge-local output keys are not implicitly promoted into the parent step's
step-local :outputs map.
Illustrative shape:
{:type :invoke
:invoke {:operation "workflow/classify-result"
:args {:result {:from {:step "build" :output :data}}}}}
All judge forms normalize to one logical outcome value.
That outcome:
:onThe IR uses one shared source-spec language for:
Current runtime ownership note:
components/agent-session/src/psi/agent_session/workflow_source_resolution.cljIllustrative shape:
{:from source-ref
:path [:issues]}
or:
{:from source-ref
:projection {:type :tail :turns 4 :tool-output false}}
A source-spec must not contain both :path and :projection in the first cut.
Illustrative source refs:
:workflow-input
:workflow-original
{:step "discover" :output :data}
{:step "discover" :yield :data}
These align with the workflow grammar's shared data-reference model and form the runtime boundary.
:workflow-input -> current workflow invocation input value:workflow-original -> current workflow invocation original request surface{:step s :output k} -> step-local output surface k from prior step s{:step s :yield f} -> yielded-value field f from prior step sFor session structured outputs, {:step s :output k} addresses the logical
parent step output key that declared :source :session/structured-output. A
source-spec :path is resolved against the validated structured :value, not
against :raw-output, :parsed-value, or prose. Resolution must fail clearly
when the source output is missing, non-structured, invalid, or lacks the requested
path.
Judge-local structured outputs declared with :source :judge/structured-output
are available to the judge result and transition evaluation only in this slice.
They are not valid downstream {:step s :output k} refs unless a future explicit
promotion/export contract adds a named parent step output. The current source-ref
grammar has no judge identifier, so implicit judge-output promotion would make
the parent step output namespace ambiguous.
Current implementation note:
:workflow-input, :workflow-original, prior-step :output, and prior-step :yield:workflow-runtime is not a canonical IR :from source-ref and target-authored definitions using it are rejected at validation timeSession construction and delegated context reuse normalized contribution items.
{:type :source
:from source-ref
source-projection?}
{:type :template
:text string
:vars {string source-spec}*}
Rules:
Delegate :prompt-string may be represented in IR either as:
Recommended first-cut shape keeps authored intent visible until boundary rendering:
:string
or
{:type :template
:text string
:vars {string source-spec}*}
Before actual delegated execution, runtime should materialize this to a final string.
The workflow result is the yielded value of the step whose chosen transition reaches :done.
Rules:
:done means that step's yielded value becomes the workflow result:done still returns the parent step's yielded valueThis section gives a compact documentation grammar for the normalized IR.
workflow-ir ::= {:version :workflow-ir/v1
:steps [ir-step+]}
ir-step ::= invoke-ir-step | session-ir-step | delegate-ir-step
invoke-ir-step ::= {:name step-name
:type :invoke
:invoke invoke-spec
outputs?
yields?
control-flow*
compat?}
session-ir-step ::= {:name step-name
:type :session
:session session-spec
outputs?
yields?
control-flow*
compat?}
delegate-ir-step ::= {:name step-name
:type :delegate
:delegate delegate-spec
outputs?
yields?
control-flow*
compat?}
invoke-spec ::= {:operation operation-id
:args {keyword (literal | source-spec)}*}
session-spec ::= {:model? model-selection-spec
:tools? [tool-id*]
:skills? [skill-id*]
:temperature? double ;; optional, range [0.0, 2.0]; absent = provider default
:contributions [contribution+]
session-extension*}
delegate-spec ::= {:target workflow-name
:prompt-string (string | template-contribution)
:context? [source-contribution*]}
control-flow ::= :judge judge-spec
| :on outcome-map
| :max-iterations pos-int
judge-spec ::= llm-judge | invoke-judge ;; both forms are current executed runtime IR
llm-judge ::= {:type :llm
:session judge-session-spec
outputs?
:projection? projection}
judge-session-spec ::= {:model? model-selection-spec
:tools? [tool-id*]
:skills? [skill-id*]
:temperature? double ;; optional, range [0.0, 2.0]; absent = provider default
:contributions [contribution+]}
invoke-judge ::= {:type :invoke
:invoke invoke-spec}
outcome-map ::= {outcome transition-map}+
transition-map ::= {:goto goto-target
:max-iterations? pos-int}
goto-target ::= :next | :previous | :done | step-name
outputs ::= {:outputs {output-key output-spec}+}
output-spec ::= text-output-spec | structured-output-spec | delegate-output-spec | invoke-output-spec
text-output-spec ::= {:source :session/final-llm-reply}
structured-output-spec ::= {:source structured-output-source
:mode :structured
:schema-id schema-id
:schema-version schema-version
:schema malli-schema
:on-invalid? invalid-policy}
structured-output-source ::= :session/structured-output | :judge/structured-output
delegate-output-spec ::= {:source :delegate/handoff}
invoke-output-spec ::= {:source keyword
output-metadata*}
invalid-policy ::= {:action :fail-fast}
| {:action :retry :max-attempts pos-int}
yields ::= {:type :data :data output-key}
| {:type :text :text output-key}
| {:type :error :reason keyword :message string :details? map}
| {:type :delegated}
contribution ::= source-contribution | template-contribution
source-contribution ::= {:type :source
:from source-ref
source-projection?}
template-contribution ::= {:type :template
:text string
:vars {var-name source-spec}*}
source-spec ::= {:from source-ref
source-projection?}
source-projection ::= :path path
| :projection projection
source-ref ::= :workflow-input
| :workflow-original
| {:step step-name :output output-key}
| {:step step-name :yield yield-field}
;; `:workflow-runtime` is intentionally not a canonical normalized IR source-ref.
output-key ::= keyword
yield-field ::= keyword
schema-id ::= keyword
schema-version ::= pos-int
malli-schema ::= vector | map | keyword
projection ::= map
compat ::= :compat map
step-name ::= string
workflow-name ::= string
operation-id ::= string
tool-id ::= string
skill-id ::= string
var-name ::= string
outcome ::= string | keyword
path ::= vector
literal ::= string | keyword | number | boolean | nil | vector | map
pos-int ::= integer
map ::= clojure-map
vector ::= clojure-vector
string ::= clojure-string
keyword ::= clojure-keyword
number ::= clojure-number
boolean ::= true | false
nil ::= nil
Use this document together with doc/workflow-grammar.md and
doc/workflow-grammar-concepts.md when changing workflow compilation,
validation, or runtime execution.
Can 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 |