Spring AI Recipe: Adding Human-in-the-Loop to a Graph-based Agentic Workflow
Sometimes the smartest thing an AI can do is admit that it’s unsure. Instead of guessing, a graph-based agentic workflow can pause, ask for human guidance, and then continue with confidence.
In the previous graph-based workflow recipe, we built a simple customer support triage system that automatically routed requests to either billing or technical support.
But what happens when the AI simply can’t tell where a question belongs? And, instead of guessing, what if the workflow could pause and ask the human for help before continuing?
In this recipe, we’ll add Human-in-the-Loop (HITL) support to the graph workflow so that ambiguous requests can be classified by the user before the workflow resumes. The new workflow looks like this:
We’ll start from the graph workflow built in the previous recipe and introduce a new interruption point where the workflow can pause, collect user input, and continue execution.
To keep the recipe brief, I’ll mostly only show code changes made to the code from the previous recipe. If you want to see the full code, you can find it and the code for other Spring AI recipes at https://github.com/habuma/spring-ai-recipes.
NOTE: This recipe has been updated since it’s original publication to reflect changes in Spring AI 2.0.0.
Adding a Human Classification Node
First, we’re going to add a new NodeAction. This node won’t actually do anything. It only serves as a checkpoint where the workflow can pause and wait for human input.
public class HumanClassificationNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) {
return Map.of();
}
}As mentioned earlier, this node is intentionally simple. Its real purpose is to provide a location in the graph where execution can be interrupted.
Updating the Classification Node
Previously, the workflow classified requests as either "billing" or "technical".
If the model wasn’t sure, it defaulted to "technical".
Now we’ll introduce a third option: "unknown".
@Component
public class ClassifySupportRequestNode implements NodeAction {
private static final Logger logger =
LoggerFactory.getLogger(ClassifySupportRequestNode.class);
private final ChatClient chatClient;
public ClassifySupportRequestNode(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
logger.info("Classifying user question");
var question = state.value("user_question", String.class)
.orElseThrow();
var category = chatClient.prompt()
.system("""
Classify the customer support request as exactly one of:
billing
technical
unknown
Respond with only the category name.
""")
.user(question)
.call()
.content()
.trim()
.toLowerCase();
return Map.of("category", category);
}
}Notice two important changes to the system message:
"unknown"was added as a valid classification.- The fallback logic that automatically defaulted to
"technical"was removed.
Now the model can explicitly indicate uncertainty instead of making a potentially incorrect routing decision.
Introducing the Node into the Graph
Next, add a new node constant to the graph configuration:
@Configuration
public class SupportGraphConfiguration {
static final String CLASSIFY = "classify";
static final String HUMAN_CLASSIFY = "human-classify"; // <-- NEW
static final String BILLING = "billing";
static final String TECHNICAL = "technical";
// ...
}And then register the node in the graph:
@Bean
CompiledGraph supportGraph(ChatClient chatClient)
throws GraphStateException {
return new StateGraph("support-triage", this::stateStrategies)
// Nodes
.addNode(CLASSIFY,
node_async(new ClassifySupportRequestNode(chatClient)))
.addNode(HUMAN_CLASSIFY,
node_async(new HumanClassificationNode())) // <-- NEW
.addNode(BILLING,
node_async(new BillingSupportNode(chatClient)))
.addNode(TECHNICAL,
node_async(new TechnicalSupportNode(chatClient)))
// ...
}Routing Unknown Requests to a Human
Previously, the graph routed directly from classification to either billing or technical support. Now we’ll route "unknown" requests to the human classification node instead.
.addConditionalEdges(
CLASSIFY,
edge_async(state -> {
String category = state.value("category", "unknown");
return switch (category) {
case BILLING -> BILLING;
case TECHNICAL -> TECHNICAL;
default -> HUMAN_CLASSIFY;
};
}),
Map.of(
BILLING, BILLING,
TECHNICAL, TECHNICAL,
HUMAN_CLASSIFY, HUMAN_CLASSIFY
))If the model determines the request is clearly billing or technical, the workflow proceeds normally. But if the classification is "unknown", the graph transitions to the human classification node where execution will pause.
Continuing After Human Input
Once the user supplies a classification, the workflow still needs to continue to either billing or technical support. So we’ll add another conditional edge:
.addConditionalEdges(
HUMAN_CLASSIFY,
edge_async(state ->
state.value("category", TECHNICAL)),
Map.of(
BILLING, BILLING,
TECHNICAL, TECHNICAL
))This edge simply routes based on the category supplied by the user.
Interrupting the Workflow
Previously, the graph was compiled with a simple compile() call. Now we’ll configure the graph to interrupt before the human classification node:
.compile(CompileConfig.builder()
.interruptBefore(HUMAN_CLASSIFY)
.build());This is the key to Human-in-the-Loop support. The graph pauses execution before the human node runs, allowing your application to gather user input before resuming the workflow.
Updating the Chat Loop
Now let’s modify the ApplicationRunner so it can handle interruptions and resume the workflow afterward.
@Bean
ApplicationRunner go(CompiledGraph compiledGraph) {
return args -> {
System.out.println("How can I help?\n");
try (Scanner scanner = new Scanner(System.in)) {
while (true) {
System.out.print("> ");
Map<String, Object> initialState = new HashMap<>();
initialState.put("user_question", scanner.nextLine());
// NEW: Create a runnable config with a unique thread ID.
// This enables the workflow to be resumed later.
RunnableConfig runnableConfig = RunnableConfig.builder()
.threadId(UUID.randomUUID().toString())
.build();
var state = compiledGraph
.invoke(initialState, runnableConfig)
.orElseThrow();
// NEW: If classification is unknown,
// ask the user for help.
if (state.data().get("category").equals("unknown")) {
RunnableConfig resumeConfig =
askUserForClassification(
compiledGraph,
scanner,
runnableConfig);
state = compiledGraph
.invoke(Map.of(), resumeConfig)
.orElseThrow();
}
System.out.println(
"\n - " + state.data().get("response"));
}
}
};
}Two important changes were made:
- The graph is now invoked with a
RunnableConfigcontaining a unique thread ID. - If the graph pauses with a category of
"unknown", the user is prompted for clarification before the workflow resumes.
Asking the User for Classification
Here’s the helper method that gathers the user’s input and updates the workflow state:
private static RunnableConfig askUserForClassification(
CompiledGraph supportGraph,
Scanner scanner,
RunnableConfig runnableConfig) throws Exception {
String humanCategory = "";
while (!humanCategory.equals("billing")
&& !humanCategory.equals("technical")) {
System.out.print("""
How would you categorize your question?
[billing or technical]:
""");
humanCategory = scanner.nextLine()
.trim()
.toLowerCase();
}
RunnableConfig resumeConfig = supportGraph
.getState(runnableConfig)
.config()
.withResume();
resumeConfig = supportGraph.updateState(
resumeConfig,
Map.of("category", humanCategory),
"human-classify"
);
return resumeConfig;
}The loop continues prompting until the user enters either "billing" or "technical". Once valid input is received:
- The workflow state is retrieved.
- The
"category"field is updated with the user’s choice. - The updated
RunnableConfigis returned so the workflow can resume.
Try It Out
Now run the application and test it.
Questions like “Why was I overcharged $50?” or “My computer is frozen.” will likely be classified automatically and routed directly to the appropriate node.
But something ambiguous like: “Do fish sleep?” will trigger the Human-in-the-Loop behavior, prompting the user to classify the request before the workflow continues.
That’s the power of graph-based workflows with interruption and resumption support: the AI can handle what it knows confidently while gracefully involving humans when needed.
What’s Next
Coming up next, we’ll build upon what we’ve done in this recipe and the previous recipe, adding an evaluation loop to the workflow.
You can find the code for this recipe and my other Spring AI recipes at https://github.com/habuma/spring-ai-recipes.
If you’re exploring Spring AI, I’ve written a book (Spring AI in Action) that covers the core foundations — and I’ll continue sharing newer patterns like this here as the ecosystem evolves. You can find my book on the publisher’s website, at Amazon, or anywhere you buy technical books.