Skip to content

4. Adding HITL

This guide assumes you’ve completed the stateful version in the previous step version.

Here we show the changes needed to add Human-In-The-Loop (HITL) support. We will treat a human interaction as a tool call that is requested by the LLM and which needs to be handled by a human.

For the changes, we’re using a new file named 4-adding-hitl.ts.

As we treat the human interaction as a tool call, we need to extend the AgentWorkflowState to include the id of this tool call:

type AgentWorkflowState = {
...
humanToolId: string | null;
};

That way we can later generate a tool response for the LLM based on the human response.

Add two new events to the workflow to request human input and to receive it:

const humanRequestEvent = workflowEvent();
const humanResponseEvent = workflowEvent<string>();

Include at least one tool whose name starts with human_ to signal that a human must provide the response.

const tools: Tool[] = [
// ...existing tools
{
type: "function" as const,
function: {
name: "human_ask_name",
description: "Ask the user for his/her name",
},
},
];

Naming the tool with a human_ prefix is just a convention for this tutorial - it’s not a restriction by @llamaindex/workflow-core. We will use this pattern in the next step to identify that the LLM is requesting a human response.

4. Branch in the tool-call handler for human tools

Section titled “4. Branch in the tool-call handler for human tools”

When the LLM requests a human_* tool, record the tool_call_id, emit a human request, and pause the workflow with a provisional final response.

workflow.handle([toolCallEvent], async (context, event) => {
const { toolCall } = event.data;
const { sendEvent, state } = context;
try {
if (toolCall.function.name.startsWith("human_")) {
state.humanToolId = toolCall.id;
sendEvent(humanRequestEvent.with());
sendEvent(finalResponseEvent.with("Waiting for human response"));
} else {
const toolResponse = await callTool(toolCall);
sendEvent(
toolResponseEvent.with({
role: "tool",
content: toolResponse,
tool_call_id: toolCall.id,
}),
);
}
} catch (error) {
// unchanged error path
}
});

We need a new handler that turns the human’s answer into a normal tool response (using the id of the human tool call stored in the state):

workflow.handle([humanResponseEvent], async (context, event) => {
const { sendEvent, state } = context;
sendEvent(
toolResponseEvent.with({
role: "tool",
content: "My name is " + event.data,
tool_call_id: state.humanToolId!,
}),
);
});

When a human interaction is requested, take a snapshot (by calling snapshot() provided by the stateful middleware), so that we can resume the workflow with the human response afterwards:

let snapshotData: SnapshotData | null = null;
const context = workflow.createContext({
expectedToolCount: 0,
messages: [],
toolResponses: [],
humanToolId: null,
});
context.stream.on(humanRequestEvent, async () => {
snapshotData = await context.snapshot();
});

To start the workflow, send an userInputEvent with the initial message to the LLM as before:

context.sendEvent(
userInputEvent.with({
messages: [
{
role: "user",
content:
"What's the weather in San Francisco and what is the user's name?",
},
],
}),
);

Then we wait for the workflow to complete the first time. Note that this works as we send a finalResponseEvent after requesting a human interaction.

await context.stream.until(finalResponseEvent).toArray();

Afterwards, we ensure a human interaction has been requested (by checking the snapshot data) and ask for the user’s name:

if (!snapshotData) {
throw new Error("No snapshot data");
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const userName = await rl.question("Name? ");
rl.close();

Then we reconstruct the workflow from the snapshot data using resume() and continue the workflow with the humanResponseEvent containing the human response:

const resumedContext = workflow.resume(snapshotData);
resumedContext.sendEvent(humanResponseEvent.with(userName));

Finally, we wait for the workflow to complete again and log the result:

const result = await resumedContext.stream.until(finalResponseEvent).toArray();
console.log(result[result.length - 1].data);
  • Safe interruption: We can pause long-running workflows at human decision points.
  • Seamless resumption: Human answers are injected as regular tool responses.
  • Composable: Multiple human steps can be added without changing prior handlers.

For the complete working example, see demo/express/4-adding-hitl.ts.