Skip to main content

What is Agent Workflow?

A workflow is a sequence of steps. Each step does one thing — calls an LLM, runs a function, invokes an external agent. Steps pass data through a shared context (typed key-value pairs). The workflow compiles to a graph IR — a pure data structure that decouples definition from execution. Three runtimes are available: LocalStepRunner (in-process, zero overhead), CheckpointingStepRunner (JDBC crash recovery), and TemporalStepRunner (distributed durable execution) — same workflow code, swap a single @Bean.
Workflow.define("pr-review")
    .step(fetchDiff)
    .then(analyzeDiff)
    .gate(new JudgeGate(jury, 0.8))
        .onPass(postComment)
        .onFail(revise)
    .end()
    .run(event);

Steps

Steps are the building blocks. Each takes input, does work, produces output.

Deterministic steps

Pure Java — no LLM, no cost:
Step<Object, Object> fetchDiff = Step.named("fetch-diff", (ctx, in) -> {
    // Call GitHub API, return the diff as a string
    return gitHub.getPullRequest(in).getDiff();
});

LLM steps

Several flavors depending on what you’re calling:
Step typeWhat it wrapsTypical duration
ChatClientStepSingle Spring AI ChatClient callSeconds
ClaudeStepFull Claude CLI agent session (many turns, many tool calls)Minutes
AgentClientStepExternal agent runtime (Claude, Gemini, etc.)Minutes
A2AStepRemote agent via Agent-to-Agent protocolMinutes
A ClaudeStep isn’t a single API call — it runs a complete agentic loop internally. The workflow sees it as one step:
Workflow
  └── Step: FetchPR          [deterministic — GitHub API call]
  └── Step: AnalyzeDiff      [ClaudeStep — full agent loop internally]
                                  ├── LLM turn 1 → tool call → result
                                  ├── LLM turn 2 → tool call → result
                                  └── LLM turn N → finish
  └── Step: AssembleReport   [deterministic — string formatting]

Creating steps with ChatClientStep

For a single LLM call, ChatClientStep wraps a Spring AI ChatClient:
ChatClient chat = ChatClient.builder(chatModel).build();

Step<Object, Object> write = Step.named("write", (ctx, in) ->
        chat.prompt()
                .user("Write a 3-sentence story about: " + in)
                .call().content());

Context

Steps communicate through AgentContext — a typed key-value store that flows through the workflow:
// Define typed keys
static final ContextKey<String> DIFF = ContextKey.of("diff", String.class);
static final ContextKey<Double> RISK_SCORE = ContextKey.of("risk-score", Double.class);

// Step reads from context and writes back
Step<Object, Object> assessRisk = Step.named("assess-risk", (ctx, in) -> {
    String diff = ctx.require(DIFF);          // read upstream output
    double score = evaluateRisk(diff);
    // score becomes this step's output, available to downstream steps
    return score;
});
The context is immutable — each step gets a snapshot, mutations produce a new instance. Parallel branches receive independent snapshots and merge at join points. The framework auto-populates Steps.outputOf("step-name") after each step, so any downstream step can read any prior step’s output by name.

Your First Workflow

Step<Object, Object> write = Step.named("write", (ctx, in) ->
        chat.prompt()
                .user("Write a 3-sentence story about: " + in)
                .call().content());

Step<Object, Object> editForAudience = Step.named("edit-audience", (ctx, in) ->
        chat.prompt()
                .user("Rewrite for young adults. Return only the story: " + in)
                .call().content());

Step<Object, Object> editForStyle = Step.named("edit-style", (ctx, in) ->
        chat.prompt()
                .user("Rewrite in a humorous style. Return only the story: " + in)
                .call().content());

String result = (String) Workflow.<String, Object>define("novel-creator")
        .step(write)
        .then(editForAudience)
        .then(editForStyle)
        .run("dragons and wizards");
Output flows forward: writeeditForAudienceeditForStyle. Each step’s output is the next step’s input.

The Graph IR

The DSL doesn’t execute directly — it builds a WorkflowGraph. This separation enables:
  • Portable runtimes — the IR decouples workflow definition from execution. Three runners ship today: LocalStepRunner, CheckpointingStepRunner (JDBC), and TemporalStepRunner (distributed)
  • Tracing — every step transition is recorded (TraceRecorder)
  • Inspection — the graph is pure data (nodes + edges), not opaque lambdas
Control flow compiles to real graph structure: a branch is 4 nodes + 4 edges; a loop has a back-edge; parallel has fork/join nodes. All visible in traces.

Prerequisites

  • Java 21+
  • Spring AI 2.0
<dependency>
    <groupId>io.github.markpollack</groupId>
    <artifactId>workflow-flows</artifactId>
    <version>0.3.0</version>
</dependency>

What’s Next

Step Parameterization

Constructor injection, input chaining, context keys — 4 patterns for getting data into steps

DSL Primitives

10+ composable patterns — branch, loop, parallel, decision, gate, supervisor

Spring Batch Mapping

How Agent Workflow maps to Spring Batch concepts

Complete Examples

8 runnable integration tests validated against GPT-4.1