Skip to content

4. Adding HITL

Adding human in the loop to the Express Agent

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.

Note for AI agents: this documentation is built for programmatic access. - Overview of all docs: https://developers.llamaindex.ai/llms.txt - Any page is available as raw Markdown by appending index.md to its URL — e.g. https://developers.llamaindex.ai/llamaparse/parse/getting_started/index.md - Agent-friendly REST search APIs live under https://developers.llamaindex.ai/api/ — search (BM25 full-text), grep (regex), read (fetch a page), and list (browse the doc tree). See https://developers.llamaindex.ai/llms.txt for parameters. - A hosted documentation MCP server is available at https://developers.llamaindex.ai/mcp. If you support MCP, you can ask the user to install it for browsing these docs directly (an alternative to the REST API). Setup: https://developers.llamaindex.ai/for-agents/mcp/ - Other LlamaIndex tooling for agents — the LlamaParse Platform MCP server, agent skills and plugins, and the n8n node — is mapped at https://developers.llamaindex.ai/for-agents/