Human-in-the-Loop Agent with Next.js
Add a human approval step to your agentic system with Next.js and the AI TOOLKIT
When building agentic systems, it's important to add human-in-the-loop (HITL) functionality to ensure that users can approve actions before the system executes them. This recipe will describe how to build a low-level solution and then provide an example abstraction you could implement and customize based on your needs.
Run it locally
$ npm install aiBackground
To understand how to implement this functionality, let's look at how tool calling works in a simple Next.js chatbot application with the AI TOOLKIT.
On the frontend, use the useChat hook to manage the message state and user interaction.
'use client';
import { useChat } from '@ai-toolkit/react';
import { DefaultChatTransport } from 'ai-toolkit';
import { useState } from 'react';
export default function Chat() {
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [input, setInput] = useState('');
return (
<div>
<div>
{messages?.map(m => (
<div key={m.id}>
<strong>{${m.role}: }</strong>
{m.parts?.map((part, i) => {
switch (part.type) {
case 'text':
return <div key={i}>{part.text}</div>;
}
})}
<br />
</div>
))}
</div>
<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input
value={input}
placeholder="Say something..."
onChange={e => setInput(e.target.value)}
/>
</form>
</div>
);
}
On the backend, create a route handler (API Route) that returns a UIMessageStreamResponse. Within the execute function of createUIMessageStream, call streamText and pass in the converted messages (sent from the client). Finally, merge the resulting generation into the UIMessageStream.
import {
createUIMessageStreamResponse,
createUIMessageStream,
streamText,
tool,
convertToModelMessages,
stepCountIs,
UIMessage,
} from 'ai-toolkit';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const stream = createUIMessageStream({
originalMessages: messages,
execute: async ({ writer }) => {
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
tools: {
getWeatherInformation: tool({
description: 'show the weather in a given city to the user',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.string(),
execute: async ({ city }) => {
const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy'];
return weatherOptions[
Math.floor(Math.random() * weatherOptions.length)
];
},
}),
},
stopWhen: stepCountIs(5),
});
writer.merge(result.toUIMessageStream({ originalMessages: messages }));
},
});
return createUIMessageStreamResponse({ stream });
}
What happens if you ask the LLM for the weather in New York?
The LLM has one tool available, weather, which requires a location to run. This tool will, as stated in the tool's description, "show the weather in a given city to the user". If the LLM decides that the weather tool could answer the user's query, it would generate a ToolCall, extracting the location from the context. The AI TOOLKIT would then run the associated execute function, passing in the location parameter, and finally returning a tool result.
To introduce a HITL step you will add a confirmation step to this process in between the tool call and the tool result.
- 1'use client';
- 2import { useChat } from '@ai-toolkit/react';
- 3import { DefaultChatTransport } from 'ai-toolkit';
- 4import { useState } from 'react';
- 5export default function Chat() {
- 6 const { messages, sendMessage } = useChat({
- 7 transport: new DefaultChatTransport({
- 8 api: '/api/chat',
- 9 }),
- 10 });
- 11 const [input, setInput] = useState('');
- 12 return (
- 13 <div>
- 14 <div>
- 15 {messages?.map(m => (
- 16 <div key={m.id}>
- 17 <strong>{`${m.role}: `}</strong>
- 18 {m.parts?.map((part, i) => {
- 19 switch (part.type) {
- 20 case 'text':
- 21 return <div key={i}>{part.text}</div>;
- 22 }
- 23 })}
- 24 <br />
- 25 </div>
- 26 ))}
- 27 </div>
- 28 <form
- 29 onSubmit={e => {
- 30 e.preventDefault();
- 31 if (input.trim()) {
- 32 sendMessage({ text: input });
- 33 setInput('');
- 34 }
- 35 }}
- 36 >
- 37 <input
- 38 value={input}
- 39 placeholder="Say something..."
- 40 onChange={e => setInput(e.target.value)}
- 41 />
- 42 </form>
- 43 </div>
- 44 );
- 45}
- 1import {
- 2 createUIMessageStreamResponse,
- 3 createUIMessageStream,
- 4 streamText,
- 5 tool,
- 6 convertToModelMessages,
- 7 stepCountIs,
- 8 UIMessage,
- 9} from 'ai-toolkit';
- 10import { z } from 'zod';
- 11export async function POST(req: Request) {
- 12 const { messages }: { messages: UIMessage[] } = await req.json();
- 13 const stream = createUIMessageStream({
- 14 originalMessages: messages,
- 15 execute: async ({ writer }) => {
- 16 const result = streamText({
- 17 model: 'openai/gpt-4o',
- 18 messages: await convertToModelMessages(messages),
- 19 tools: {
- 20 getWeatherInformation: tool({
- 21 description: 'show the weather in a given city to the user',
- 22 inputSchema: z.object({ city: z.string() }),
- 23 outputSchema: z.string(),
- 24 execute: async ({ city }) => {
- 25 const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy'];
- 26 return weatherOptions[
- 27 Math.floor(Math.random() * weatherOptions.length)
- 28 ];
- 29 },
- 30 }),
- 31 },
- 32 stopWhen: stepCountIs(5),
- 33 });
- 34 writer.merge(result.toUIMessageStream({ originalMessages: messages }));
- 35 },
- 36 });
- 37 return createUIMessageStreamResponse({ stream });
- 38}
Adding a Confirmation Step
At a high level, you will:
1. Intercept tool calls before they are executed
2. Render a confirmation UI with Yes/No buttons
3. Send a temporary tool result indicating whether the user confirmed or declined
4. On the server, check for the confirmation state in the tool result:
- If confirmed, execute the tool and update the result
- If declined, update the result with an error message
5. Send the updated tool result back to the client to maintain state consistency
### Forward Tool Call To The Client
To implement HITL functionality, you start by omitting the execute function from the tool definition. This allows the frontend to intercept the tool call and handle the responsibility of adding the final tool result to the tool call.
import {
createUIMessageStreamResponse,
createUIMessageStream,
streamText,
tool,
convertToModelMessages,
stepCountIs,
} from 'ai-toolkit';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages } = await req.json();
const stream = createUIMessageStream({
originalMessages: messages,
execute: async ({ writer }) => {
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
tools: {
getWeatherInformation: tool({
description: 'show the weather in a given city to the user',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.string(),
// execute function removed to stop automatic execution
}),
},
stopWhen: stepCountIs(5),
});
writer.merge(result.toUIMessageStream({ originalMessages: messages })); // pass in original messages to avoid duplicate assistant messages
},
});
return createUIMessageStreamResponse({ stream });
}
<Note type="warning">
Each tool call must have a corresponding tool result. If you do not add a tool
result, all subsequent generations will fail.
</Note>
### Intercept Tool Call
On the frontend, you map through the messages, either rendering the message content or checking for tool invocations and rendering custom UI.
You can check if the tool requiring confirmation has been called and, if so, present options to either confirm or deny the proposed tool call. This confirmation is done using the addToolOutput function to create a tool result and append it to the associated tool call.
'use client';
import { useChat } from '@ai-toolkit/react';
import {
DefaultChatTransport,
isStaticToolUIPart,
getStaticToolName,
} from 'ai-toolkit';
import { useState } from 'react';
export default function Chat() {
const { messages, addToolOutput, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [input, setInput] = useState('');
return (
<div>
<div>
{messages?.map(m => (
<div key={m.id}>
<strong>{${m.role}: }</strong>
{m.parts?.map((part, i) => {
if (part.type === 'text') {
return <div key={i}>{part.text}</div>;
}
if (isStaticToolUIPart(part)) {
const toolName = getStaticToolName(part);
const toolCallId = part.toolCallId;
// render confirmation tool (client-side tool with user interaction)
if (
toolName === 'getWeatherInformation' &&
part.state === 'input-available'
) {
return (
<div key={toolCallId}>
Get weather information for {part.input.city}?
<div>
<button
onClick={async () => {
await addToolOutput({
toolCallId,
tool: toolName,
output: 'Yes, confirmed.',
});
sendMessage();
}}
>
Yes
</button>
<button
onClick={async () => {
await addToolOutput({
toolCallId,
tool: toolName,
output: 'No, denied.',
});
sendMessage();
}}
>
No
</button>
</div>
</div>
);
}
}
})}
<br />
</div>
))}
</div>
<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input
value={input}
placeholder="Say something..."
onChange={e => setInput(e.target.value)}
/>
</form>
</div>
);
}
<Note>
The sendMessage() function after addToolOutput will trigger a call to your
route handler.
</Note>
### Handle Confirmation Response
Adding a tool result and sending the message will trigger another call to your route handler. Before sending the new messages to the language model, you pull out the last message and map through the message parts to see if the tool requiring confirmation was called and whether it's in a "result" state. If those conditions are met, you check the confirmation state (the tool result state that you set on the frontend with the addToolOutput function).
import {
createUIMessageStreamResponse,
createUIMessageStream,
streamText,
tool,
convertToModelMessages,
stepCountIs,
isStaticToolUIPart,
getStaticToolName,
UIMessage,
} from 'ai-toolkit';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const stream = createUIMessageStream({
originalMessages: messages,
execute: async ({ writer }) => {
// pull out last message
const lastMessage = messages[messages.length - 1];
lastMessage.parts = await Promise.all(
// map through all message parts
lastMessage.parts?.map(async part => {
if (!isStaticToolUIPart(part)) {
return part;
}
const toolName = getStaticToolName(part);
// return if tool isn't weather tool or in a output-available state
if (
toolName !== 'getWeatherInformation' ||
part.state !== 'output-available'
) {
return part;
}
// switch through tool output states (set on the frontend)
switch (part.output) {
case 'Yes, confirmed.': {
const result = await executeWeatherTool(part.input);
// forward updated tool result to the client:
writer.write({
type: 'tool-output-available',
toolCallId: part.toolCallId,
output: result,
});
// update the message part:
return { ...part, output: result };
}
case 'No, denied.': {
const result = 'Error: User denied access to weather information';
// forward updated tool result to the client:
writer.write({
type: 'tool-output-available',
toolCallId: part.toolCallId,
output: result,
});
// update the message part:
return { ...part, output: result };
}
default:
return part;
}
}) ?? [],
);
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
tools: {
getWeatherInformation: tool({
description: 'show the weather in a given city to the user',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.string(),
}),
},
stopWhen: stepCountIs(5),
});
writer.merge(result.toUIMessageStream({ originalMessages: messages }));
},
});
return createUIMessageStreamResponse({ stream });
}
async function executeWeatherTool({ city }: { city: string }) {
const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy'];
return weatherOptions[Math.floor(Math.random() * weatherOptions.length)];
}
In this implementation, you use simple strings like "Yes, the user confirmed" or "No, the user declined" as states. If confirmed, you execute the tool. If declined, you do not execute the tool. In both cases, you update the tool result from the arbitrary data you sent with the addToolOutput function to either the result of the execute function or an "Execution declined" statement. You send the updated tool result back to the frontend to maintain state synchronization.
After handling the tool result, your API route continues. This triggers another generation with the updated tool result, allowing the LLM to continue attempting to solve the query.
- 1import {
- 2 createUIMessageStreamResponse,
- 3 createUIMessageStream,
- 4 streamText,
- 5 tool,
- 6 convertToModelMessages,
- 7 stepCountIs,
- 8} from 'ai-toolkit';
- 9import { z } from 'zod';
- 10export async function POST(req: Request) {
- 11 const { messages } = await req.json();
- 12 const stream = createUIMessageStream({
- 13 originalMessages: messages,
- 14 execute: async ({ writer }) => {
- 15 const result = streamText({
- 16 model: 'openai/gpt-4o',
- 17 messages: await convertToModelMessages(messages),
- 18 tools: {
- 19 getWeatherInformation: tool({
- 20 description: 'show the weather in a given city to the user',
- 21 inputSchema: z.object({ city: z.string() }),
- 22 outputSchema: z.string(),
- 23 // execute function removed to stop automatic execution
- 24 }),
- 25 },
- 26 stopWhen: stepCountIs(5),
- 27 });
- 28 writer.merge(result.toUIMessageStream({ originalMessages: messages })); // pass in original messages to avoid duplicate assistant messages
- 29 },
- 30 });
- 31 return createUIMessageStreamResponse({ stream });
- 32}
- 1'use client';
- 2import { useChat } from '@ai-toolkit/react';
- 3import {
- 4 DefaultChatTransport,
- 5 isStaticToolUIPart,
- 6 getStaticToolName,
- 7} from 'ai-toolkit';
- 8import { useState } from 'react';
- 9export default function Chat() {
- 10 const { messages, addToolOutput, sendMessage } = useChat({
- 11 transport: new DefaultChatTransport({
- 12 api: '/api/chat',
- 13 }),
- 14 });
- 15 const [input, setInput] = useState('');
- 16 return (
- 17 <div>
- 18 <div>
- 19 {messages?.map(m => (
- 20 <div key={m.id}>
- 21 <strong>{`${m.role}: `}</strong>
- 22 {m.parts?.map((part, i) => {
- 23 if (part.type === 'text') {
- 24 return <div key={i}>{part.text}</div>;
- 25 }
- 26 if (isStaticToolUIPart(part)) {
- 27 const toolName = getStaticToolName(part);
- 28 const toolCallId = part.toolCallId;
- 29 // render confirmation tool (client-side tool with user interaction)
- 30 if (
- 31 toolName === 'getWeatherInformation' &&
- 32 part.state === 'input-available'
- 33 ) {
- 34 return (
- 35 <div key={toolCallId}>
- 36 Get weather information for {part.input.city}?
- 37 <div>
- 38 <button
- 39 onClick={async () => {
- 40 await addToolOutput({
- 41 toolCallId,
- 42 tool: toolName,
- 43 output: 'Yes, confirmed.',
- 44 });
- 45 sendMessage();
- 46 }}
- 47 >
- 48 Yes
- 49 </button>
- 50 <button
- 51 onClick={async () => {
- 52 await addToolOutput({
- 53 toolCallId,
- 54 tool: toolName,
- 55 output: 'No, denied.',
- 56 });
- 57 sendMessage();
- 58 }}
- 59 >
- 60 No
- 61 </button>
- 62 </div>
- 63 </div>
- 64 );
- 65 }
- 66 }
- 67 })}
- 68 <br />
- 69 </div>
- 70 ))}
- 71 </div>
- 72 <form
- 73 onSubmit={e => {
- 74 e.preventDefault();
- 75 if (input.trim()) {
- 76 sendMessage({ text: input });
- 77 setInput('');
- 78 }
- 79 }}
- 80 >
- 81 <input
- 82 value={input}
- 83 placeholder="Say something..."
- 84 onChange={e => setInput(e.target.value)}
- 85 />
- 86 </form>
- 87 </div>
- 88 );
- 89}
- 1import {
- 2 createUIMessageStreamResponse,
- 3 createUIMessageStream,
- 4 streamText,
- 5 tool,
- 6 convertToModelMessages,
- 7 stepCountIs,
- 8 isStaticToolUIPart,
- 9 getStaticToolName,
- 10 UIMessage,
- 11} from 'ai-toolkit';
- 12import { z } from 'zod';
- 13export async function POST(req: Request) {
- 14 const { messages }: { messages: UIMessage[] } = await req.json();
- 15 const stream = createUIMessageStream({
- 16 originalMessages: messages,
- 17 execute: async ({ writer }) => {
- 18 // pull out last message
- 19 const lastMessage = messages[messages.length - 1];
- 20 lastMessage.parts = await Promise.all(
- 21 // map through all message parts
- 22 lastMessage.parts?.map(async part => {
- 23 if (!isStaticToolUIPart(part)) {
- 24 return part;
- 25 }
- 26 const toolName = getStaticToolName(part);
- 27 // return if tool isn't weather tool or in a output-available state
- 28 if (
- 29 toolName !== 'getWeatherInformation' ||
- 30 part.state !== 'output-available'
- 31 ) {
- 32 return part;
- 33 }
- 34 // switch through tool output states (set on the frontend)
- 35 switch (part.output) {
- 36 case 'Yes, confirmed.': {
- 37 const result = await executeWeatherTool(part.input);
- 38 // forward updated tool result to the client:
- 39 writer.write({
- 40 type: 'tool-output-available',
- 41 toolCallId: part.toolCallId,
- 42 output: result,
- 43 });
- 44 // update the message part:
- 45 return { ...part, output: result };
- 46 }
- 47 case 'No, denied.': {
- 48 const result = 'Error: User denied access to weather information';
- 49 // forward updated tool result to the client:
- 50 writer.write({
- 51 type: 'tool-output-available',
- 52 toolCallId: part.toolCallId,
- 53 output: result,
- 54 });
- 55 // update the message part:
- 56 return { ...part, output: result };
- 57 }
- 58 default:
- 59 return part;
- 60 }
- 61 }) ?? [],
- 62 );
- 63 const result = streamText({
- 64 model: 'openai/gpt-4o',
- 65 messages: await convertToModelMessages(messages),
- 66 tools: {
- 67 getWeatherInformation: tool({
- 68 description: 'show the weather in a given city to the user',
- 69 inputSchema: z.object({ city: z.string() }),
- 70 outputSchema: z.string(),
- 71 }),
- 72 },
- 73 stopWhen: stepCountIs(5),
- 74 });
- 75 writer.merge(result.toUIMessageStream({ originalMessages: messages }));
- 76 },
- 77 });
- 78 return createUIMessageStreamResponse({ stream });
- 79}
- 80async function executeWeatherTool({ city }: { city: string }) {
- 81 const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy'];
- 82 return weatherOptions[Math.floor(Math.random() * weatherOptions.length)];
- 83}
Building your own abstraction
The solution above is low-level and not very friendly to use in a production environment. You can build your own abstraction using these concepts
Move tool declarations to their own file
First, you will need to move tool declarations to their own file:
import { tool, ToolSet } from 'ai-toolkit';
import { z } from 'zod';
const getWeatherInformation = tool({
description: 'show the weather in a given city to the user',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.string(), // must define outputSchema
// no execute function, we want human in the loop
});
const getLocalTime = tool({
description: 'get the local time for a specified location',
inputSchema: z.object({ location: z.string() }),
outputSchema: z.string(),
// including execute function -> no confirmation required
execute: async ({ location }) => {
console.log(Getting local time for ${location});
return '10am';
},
});
export const tools = {
getWeatherInformation,
getLocalTime,
} satisfies ToolSet;
In this file, you have two tools, getWeatherInformation (requires confirmation to run) and getLocalTime.
### Create Type Definitions
Create a types file to define a custom message type:
import { InferUITools, UIDataTypes, UIMessage } from 'ai-toolkit';
import { tools } from './tools';
export type MyTools = InferUITools<typeof tools>;
// Define custom message type
export type HumanInTheLoopUIMessage = UIMessage<
never, // metadata type
UIDataTypes, // data parts type
MyTools // tools type
>;
### Create Utility Functions
import {
convertToModelMessages,
Tool,
ToolExecutionOptions,
ToolSet,
UIMessageStreamWriter,
getStaticToolName,
isStaticToolUIPart,
} from 'ai-toolkit';
import { HumanInTheLoopUIMessage } from './types';
// Approval string to be shared across frontend and backend
export const APPROVAL = {
YES: 'Yes, confirmed.',
NO: 'No, denied.',
} as const;
function isValidToolName<K extends PropertyKey, T extends object>(
key: K,
obj: T,
): key is K & keyof T {
return key in obj;
}
/**
* Processes tool invocations where human input is required, executing tools when authorized.
*
* @param options - The function options
* @param options.tools - Map of tool names to Tool instances that may expose execute functions
* @param options.writer - UIMessageStream writer for sending results back to the client
* @param options.messages - Array of messages to process
* @param executionFunctions - Map of tool names to execute functions
* @returns Promise resolving to the processed messages
*/
export async function processToolCalls<
Tools extends ToolSet,
ExecutableTools extends {
[Tool in keyof Tools as Tools[Tool] extends { execute: Function }
? never
: Tool]: Tools[Tool];
},
>(
{
writer,
messages,
}: {
tools: Tools; // used for type inference
writer: UIMessageStreamWriter;
messages: HumanInTheLoopUIMessage[]; // IMPORTANT: replace with your message type
},
executeFunctions: {
[K in keyof Tools & keyof ExecutableTools]?: (
args: ExecutableTools[K] extends Tool<infer P> ? P : never,
context: ToolExecutionOptions,
) => Promise<any>;
},
): Promise<HumanInTheLoopUIMessage[]> {
const lastMessage = messages[messages.length - 1];
const parts = lastMessage.parts;
if (!parts) return messages;
const processedParts = await Promise.all(
parts.map(async part => {
// Only process tool invocations parts
if (!isStaticToolUIPart(part)) return part;
const toolName = getStaticToolName(part);
// Only continue if we have an execute function for the tool (meaning it requires confirmation) and it's in a 'output-available' state
if (!(toolName in executeFunctions) || part.state !== 'output-available')
return part;
let result;
if (part.output === APPROVAL.YES) {
// Get the tool and check if the tool has an execute function.
if (
!isValidToolName(toolName, executeFunctions) ||
part.state !== 'output-available'
) {
return part;
}
const toolInstance = executeFunctions[toolName] as Tool['execute'];
if (toolInstance) {
result = await toolInstance(part.input, {
messages: await convertToModelMessages(messages),
toolCallId: part.toolCallId,
});
} else {
result = 'Error: No execute function found on tool';
}
} else if (part.output === APPROVAL.NO) {
result = 'Error: User denied access to tool execution';
} else {
// For any unhandled responses, return the original part.
return part;
}
// Forward updated tool result to the client.
writer.write({
type: 'tool-output-available',
toolCallId: part.toolCallId,
output: result,
});
// Return updated toolInvocation with the actual result.
return {
...part,
output: result,
};
}),
);
// Finally return the processed messages
return [...messages.slice(0, -1), { ...lastMessage, parts: processedParts }];
}
export function getToolsRequiringConfirmation<T extends ToolSet>(
tools: T,
): string[] {
return (Object.keys(tools) as (keyof T)[]).filter(key => {
const maybeTool = tools[key];
return typeof maybeTool.execute !== 'function';
}) as string[];
}
In this file, you first declare the confirmation strings as constants so we can share them across the frontend and backend (reducing possible errors). Next, we create function called processToolCalls which takes in the messages, tools, and the writer. It also takes in a second parameter, executeFunction, which is an object that maps toolName to the functions that will be run upon human confirmation. This function is strongly typed so:
- it autocompletes executableTools - these are tools without an execute function
- provides full type-safety for arguments and options available within the execute function
Unlike the low-level example, this will return a modified array of messages that can be passed directly to the LLM.
Finally, you declare a function called getToolsRequiringConfirmation that takes your tools as an argument and then will return the names of your tools without execute functions (in an array of strings). This avoids the need to manually write out and check for toolName's on the frontend.
### Update Route Handler
Update your route handler to use the processToolCalls utility function.
import {
createUIMessageStreamResponse,
createUIMessageStream,
streamText,
convertToModelMessages,
stepCountIs,
} from 'ai-toolkit';
import { processToolCalls } from './utils';
import { tools } from './tools';
import { HumanInTheLoopUIMessage } from './types';
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: HumanInTheLoopUIMessage[] } =
await req.json();
const stream = createUIMessageStream({
originalMessages: messages,
execute: async ({ writer }) => {
// Utility function to handle tools that require human confirmation
// Checks for confirmation in last message and then runs associated tool
const processedMessages = await processToolCalls(
{
messages,
writer,
tools,
},
{
// type-safe object for tools without an execute function
getWeatherInformation: async ({ city }) => {
const conditions = ['sunny', 'cloudy', 'rainy', 'snowy'];
return `The weather in ${city} is ${
conditions[Math.floor(Math.random() * conditions.length)]
}.`;
},
},
);
const result = streamText({
model: 'openai/gpt-4o',
messages: convertToModelMessages(processedMessages),
tools,
stopWhen: stepCountIs(5),
});
writer.merge(
result.toUIMessageStream({ originalMessages: processedMessages }),
);
},
});
return createUIMessageStreamResponse({ stream });
}
### Update Frontend
Finally, update the frontend to use the new getToolsRequiringConfirmation function and the APPROVAL values:
'use client';
import { useChat } from '@ai-toolkit/react';
import {
DefaultChatTransport,
getStaticToolName,
isStaticToolUIPart,
} from 'ai-toolkit';
import { tools } from '../api/chat/tools';
import { APPROVAL, getToolsRequiringConfirmation } from '../api/chat/utils';
import { useState } from 'react';
import { HumanInTheLoopUIMessage, MyTools } from '../api/chat/types';
export default function Chat() {
const { messages, addToolOutput, sendMessage } =
useChat<HumanInTheLoopUIMessage>({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [input, setInput] = useState('');
const toolsRequiringConfirmation = getToolsRequiringConfirmation(tools);
// used to disable input while confirmation is pending
const pendingToolCallConfirmation = messages.some(m =>
m.parts?.some(
part =>
isStaticToolUIPart(part) &&
part.state === 'input-available' &&
toolsRequiringConfirmation.includes(getStaticToolName(part)),
),
);
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages?.map(m => (
<div key={m.id} className="whitespace-pre-wrap">
<strong>{${m.role}: }</strong>
{m.parts?.map((part, i) => {
if (part.type === 'text') {
return <div key={i}>{part.text}</div>;
}
if (isStaticToolUIPart<MyTools>(part)) {
const toolName = getStaticToolName(part);
const toolCallId = part.toolCallId;
const dynamicInfoStyles = 'font-mono bg-zinc-100 p-1 text-sm';
// render confirmation tool (client-side tool with user interaction)
if (
toolsRequiringConfirmation.includes(toolName) &&
part.state === 'input-available'
) {
return (
<div key={toolCallId}>
Run <span className={dynamicInfoStyles}>{toolName}</span>{' '}
with args: <br />
<span className={dynamicInfoStyles}>
{JSON.stringify(part.input, null, 2)}
</span>
<div className="flex gap-2 pt-2">
<button
className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
onClick={async () => {
await addToolOutput({
toolCallId,
tool: toolName,
output: APPROVAL.YES,
});
sendMessage();
}}
>
Yes
</button>
<button
className="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700"
onClick={async () => {
await addToolOutput({
toolCallId,
tool: toolName,
output: APPROVAL.NO,
});
sendMessage();
}}
>
No
</button>
</div>
</div>
);
}
}
})}
<br />
</div>
))}
<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input
disabled={pendingToolCallConfirmation}
className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={e => setInput(e.target.value)}
/>
</form>
</div>
);
}
- 1import { tool, ToolSet } from 'ai-toolkit';
- 2import { z } from 'zod';
- 3const getWeatherInformation = tool({
- 4 description: 'show the weather in a given city to the user',
- 5 inputSchema: z.object({ city: z.string() }),
- 6 outputSchema: z.string(), // must define outputSchema
- 7 // no execute function, we want human in the loop
- 8});
- 9const getLocalTime = tool({
- 10 description: 'get the local time for a specified location',
- 11 inputSchema: z.object({ location: z.string() }),
- 12 outputSchema: z.string(),
- 13 // including execute function -> no confirmation required
- 14 execute: async ({ location }) => {
- 15 console.log(`Getting local time for ${location}`);
- 16 return '10am';
- 17 },
- 18});
- 19export const tools = {
- 20 getWeatherInformation,
- 21 getLocalTime,
- 22} satisfies ToolSet;
- 1import { InferUITools, UIDataTypes, UIMessage } from 'ai-toolkit';
- 2import { tools } from './tools';
- 3export type MyTools = InferUITools<typeof tools>;
- 4// Define custom message type
- 5export type HumanInTheLoopUIMessage = UIMessage<
- 6 never, // metadata type
- 7 UIDataTypes, // data parts type
- 8 MyTools // tools type
- 9>;
- 1import {
- 2 convertToModelMessages,
- 3 Tool,
- 4 ToolExecutionOptions,
- 5 ToolSet,
- 6 UIMessageStreamWriter,
- 7 getStaticToolName,
- 8 isStaticToolUIPart,
- 9} from 'ai-toolkit';
- 10import { HumanInTheLoopUIMessage } from './types';
- 11// Approval string to be shared across frontend and backend
- 12export const APPROVAL = {
- 13 YES: 'Yes, confirmed.',
- 14 NO: 'No, denied.',
- 15} as const;
- 16function isValidToolName<K extends PropertyKey, T extends object>(
- 17 key: K,
- 18 obj: T,
- 19): key is K & keyof T {
- 20 return key in obj;
- 21}
- 22/**
- 23 * Processes tool invocations where human input is required, executing tools when authorized.
- 24 *
- 25 * @param options - The function options
- 26 * @param options.tools - Map of tool names to Tool instances that may expose execute functions
- 27 * @param options.writer - UIMessageStream writer for sending results back to the client
- 28 * @param options.messages - Array of messages to process
- 29 * @param executionFunctions - Map of tool names to execute functions
- 30 * @returns Promise resolving to the processed messages
- 31 */
- 32export async function processToolCalls<
- 33 Tools extends ToolSet,
- 34 ExecutableTools extends {
- 35 [Tool in keyof Tools as Tools[Tool] extends { execute: Function }
- 36 ? never
- 37 : Tool]: Tools[Tool];
- 38 },
- 39>(
- 40 {
- 41 writer,
- 42 messages,
- 43 }: {
- 44 tools: Tools; // used for type inference
- 45 writer: UIMessageStreamWriter;
- 46 messages: HumanInTheLoopUIMessage[]; // IMPORTANT: replace with your message type
- 47 },
- 48 executeFunctions: {
- 49 [K in keyof Tools & keyof ExecutableTools]?: (
- 50 args: ExecutableTools[K] extends Tool<infer P> ? P : never,
- 51 context: ToolExecutionOptions,
- 52 ) => Promise<any>;
- 53 },
- 54): Promise<HumanInTheLoopUIMessage[]> {
- 55 const lastMessage = messages[messages.length - 1];
- 56 const parts = lastMessage.parts;
- 57 if (!parts) return messages;
- 58 const processedParts = await Promise.all(
- 59 parts.map(async part => {
- 60 // Only process tool invocations parts
- 61 if (!isStaticToolUIPart(part)) return part;
- 62 const toolName = getStaticToolName(part);
- 63 // Only continue if we have an execute function for the tool (meaning it requires confirmation) and it's in a 'output-available' state
- 64 if (!(toolName in executeFunctions) || part.state !== 'output-available')
- 65 return part;
- 66 let result;
- 67 if (part.output === APPROVAL.YES) {
- 68 // Get the tool and check if the tool has an execute function.
- 69 if (
- 70 !isValidToolName(toolName, executeFunctions) ||
- 71 part.state !== 'output-available'
- 72 ) {
- 73 return part;
- 74 }
- 75 const toolInstance = executeFunctions[toolName] as Tool['execute'];
- 76 if (toolInstance) {
- 77 result = await toolInstance(part.input, {
- 78 messages: await convertToModelMessages(messages),
- 79 toolCallId: part.toolCallId,
- 80 });
- 81 } else {
- 82 result = 'Error: No execute function found on tool';
- 83 }
- 84 } else if (part.output === APPROVAL.NO) {
- 85 result = 'Error: User denied access to tool execution';
- 86 } else {
- 87 // For any unhandled responses, return the original part.
- 88 return part;
- 89 }
- 90 // Forward updated tool result to the client.
- 91 writer.write({
- 92 type: 'tool-output-available',
- 93 toolCallId: part.toolCallId,
- 94 output: result,
- 95 });
- 96 // Return updated toolInvocation with the actual result.
- 97 return {
- 98 ...part,
- 99 output: result,
- 100 };
- 101 }),
- 102 );
- 103 // Finally return the processed messages
- 104 return [...messages.slice(0, -1), { ...lastMessage, parts: processedParts }];
- 105}
- 106export function getToolsRequiringConfirmation<T extends ToolSet>(
- 107 tools: T,
- 108): string[] {
- 109 return (Object.keys(tools) as (keyof T)[]).filter(key => {
- 110 const maybeTool = tools[key];
- 111 return typeof maybeTool.execute !== 'function';
- 112 }) as string[];
- 113}
- 1import {
- 2 createUIMessageStreamResponse,
- 3 createUIMessageStream,
- 4 streamText,
- 5 convertToModelMessages,
- 6 stepCountIs,
- 7} from 'ai-toolkit';
- 8import { processToolCalls } from './utils';
- 9import { tools } from './tools';
- 10import { HumanInTheLoopUIMessage } from './types';
- 11// Allow streaming responses up to 30 seconds
- 12export const maxDuration = 30;
- 13export async function POST(req: Request) {
- 14 const { messages }: { messages: HumanInTheLoopUIMessage[] } =
- 15 await req.json();
- 16 const stream = createUIMessageStream({
- 17 originalMessages: messages,
- 18 execute: async ({ writer }) => {
- 19 // Utility function to handle tools that require human confirmation
- 20 // Checks for confirmation in last message and then runs associated tool
- 21 const processedMessages = await processToolCalls(
- 22 {
- 23 messages,
- 24 writer,
- 25 tools,
- 26 },
- 27 {
- 28 // type-safe object for tools without an execute function
- 29 getWeatherInformation: async ({ city }) => {
- 30 const conditions = ['sunny', 'cloudy', 'rainy', 'snowy'];
- 31 return `The weather in ${city} is ${
- 32 conditions[Math.floor(Math.random() * conditions.length)]
- 33 }.`;
- 34 },
- 35 },
- 36 );
- 37 const result = streamText({
- 38 model: 'openai/gpt-4o',
- 39 messages: convertToModelMessages(processedMessages),
- 40 tools,
- 41 stopWhen: stepCountIs(5),
- 42 });
- 43 writer.merge(
- 44 result.toUIMessageStream({ originalMessages: processedMessages }),
- 45 );
- 46 },
- 47 });
- 48 return createUIMessageStreamResponse({ stream });
- 49}
- 1'use client';
- 2import { useChat } from '@ai-toolkit/react';
- 3import {
- 4 DefaultChatTransport,
- 5 getStaticToolName,
- 6 isStaticToolUIPart,
- 7} from 'ai-toolkit';
- 8import { tools } from '../api/chat/tools';
- 9import { APPROVAL, getToolsRequiringConfirmation } from '../api/chat/utils';
- 10import { useState } from 'react';
- 11import { HumanInTheLoopUIMessage, MyTools } from '../api/chat/types';
- 12export default function Chat() {
- 13 const { messages, addToolOutput, sendMessage } =
- 14 useChat<HumanInTheLoopUIMessage>({
- 15 transport: new DefaultChatTransport({
- 16 api: '/api/chat',
- 17 }),
- 18 });
- 19 const [input, setInput] = useState('');
- 20 const toolsRequiringConfirmation = getToolsRequiringConfirmation(tools);
- 21 // used to disable input while confirmation is pending
- 22 const pendingToolCallConfirmation = messages.some(m =>
- 23 m.parts?.some(
- 24 part =>
- 25 isStaticToolUIPart(part) &&
- 26 part.state === 'input-available' &&
- 27 toolsRequiringConfirmation.includes(getStaticToolName(part)),
- 28 ),
- 29 );
- 30 return (
- 31 <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
- 32 {messages?.map(m => (
- 33 <div key={m.id} className="whitespace-pre-wrap">
- 34 <strong>{`${m.role}: `}</strong>
- 35 {m.parts?.map((part, i) => {
- 36 if (part.type === 'text') {
- 37 return <div key={i}>{part.text}</div>;
- 38 }
- 39 if (isStaticToolUIPart<MyTools>(part)) {
- 40 const toolName = getStaticToolName(part);
- 41 const toolCallId = part.toolCallId;
- 42 const dynamicInfoStyles = 'font-mono bg-zinc-100 p-1 text-sm';
- 43 // render confirmation tool (client-side tool with user interaction)
- 44 if (
- 45 toolsRequiringConfirmation.includes(toolName) &&
- 46 part.state === 'input-available'
- 47 ) {
- 48 return (
- 49 <div key={toolCallId}>
- 50 Run <span className={dynamicInfoStyles}>{toolName}</span>{' '}
- 51 with args: <br />
- 52 <span className={dynamicInfoStyles}>
- 53 {JSON.stringify(part.input, null, 2)}
- 54 </span>
- 55 <div className="flex gap-2 pt-2">
- 56 <button
- 57 className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
- 58 onClick={async () => {
- 59 await addToolOutput({
- 60 toolCallId,
- 61 tool: toolName,
- 62 output: APPROVAL.YES,
- 63 });
- 64 sendMessage();
- 65 }}
- 66 >
- 67 Yes
- 68 </button>
- 69 <button
- 70 className="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700"
- 71 onClick={async () => {
- 72 await addToolOutput({
- 73 toolCallId,
- 74 tool: toolName,
- 75 output: APPROVAL.NO,
- 76 });
- 77 sendMessage();
- 78 }}
- 79 >
- 80 No
- 81 </button>
- 82 </div>
- 83 </div>
- 84 );
- 85 }
- 86 }
- 87 })}
- 88 <br />
- 89 </div>
- 90 ))}
- 91 <form
- 92 onSubmit={e => {
- 93 e.preventDefault();
- 94 if (input.trim()) {
- 95 sendMessage({ text: input });
- 96 setInput('');
- 97 }
- 98 }}
- 99 >
- 100 <input
- 101 disabled={pendingToolCallConfirmation}
- 102 className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 rounded shadow-xl"
- 103 value={input}
- 104 placeholder="Say something..."
- 105 onChange={e => setInput(e.target.value)}
- 106 />
- 107 </form>
- 108 </div>
- 109 );
- 110}
Full Example
To see this code in action, check out the `next-openai` example in the AI TOOLKIT repository. Navigate to the /use-chat-human-in-the-loop page and associated route handler.