Human-in-the-Loop Agent with Next.js

Add a human approval step to your agentic system with Next.js and the AI TOOLKIT

15 min readnextagentstool useView source

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 ai

Background

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.

app/page.tsx
tsx
  1. 1'use client';
  2. 2import { useChat } from '@ai-toolkit/react';
  3. 3import { DefaultChatTransport } from 'ai-toolkit';
  4. 4import { useState } from 'react';
  5. 5export default function Chat() {
  6. 6 const { messages, sendMessage } = useChat({
  7. 7 transport: new DefaultChatTransport({
  8. 8 api: '/api/chat',
  9. 9 }),
  10. 10 });
  11. 11 const [input, setInput] = useState('');
  12. 12 return (
  13. 13 <div>
  14. 14 <div>
  15. 15 {messages?.map(m => (
  16. 16 <div key={m.id}>
  17. 17 <strong>{`${m.role}: `}</strong>
  18. 18 {m.parts?.map((part, i) => {
  19. 19 switch (part.type) {
  20. 20 case 'text':
  21. 21 return <div key={i}>{part.text}</div>;
  22. 22 }
  23. 23 })}
  24. 24 <br />
  25. 25 </div>
  26. 26 ))}
  27. 27 </div>
  28. 28 <form
  29. 29 onSubmit={e => {
  30. 30 e.preventDefault();
  31. 31 if (input.trim()) {
  32. 32 sendMessage({ text: input });
  33. 33 setInput('');
  34. 34 }
  35. 35 }}
  36. 36 >
  37. 37 <input
  38. 38 value={input}
  39. 39 placeholder="Say something..."
  40. 40 onChange={e => setInput(e.target.value)}
  41. 41 />
  42. 42 </form>
  43. 43 </div>
  44. 44 );
  45. 45}
api/chat/route.ts
ts
  1. 1import {
  2. 2 createUIMessageStreamResponse,
  3. 3 createUIMessageStream,
  4. 4 streamText,
  5. 5 tool,
  6. 6 convertToModelMessages,
  7. 7 stepCountIs,
  8. 8 UIMessage,
  9. 9} from 'ai-toolkit';
  10. 10import { z } from 'zod';
  11. 11export async function POST(req: Request) {
  12. 12 const { messages }: { messages: UIMessage[] } = await req.json();
  13. 13 const stream = createUIMessageStream({
  14. 14 originalMessages: messages,
  15. 15 execute: async ({ writer }) => {
  16. 16 const result = streamText({
  17. 17 model: 'openai/gpt-4o',
  18. 18 messages: await convertToModelMessages(messages),
  19. 19 tools: {
  20. 20 getWeatherInformation: tool({
  21. 21 description: 'show the weather in a given city to the user',
  22. 22 inputSchema: z.object({ city: z.string() }),
  23. 23 outputSchema: z.string(),
  24. 24 execute: async ({ city }) => {
  25. 25 const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy'];
  26. 26 return weatherOptions[
  27. 27 Math.floor(Math.random() * weatherOptions.length)
  28. 28 ];
  29. 29 },
  30. 30 }),
  31. 31 },
  32. 32 stopWhen: stepCountIs(5),
  33. 33 });
  34. 34 writer.merge(result.toUIMessageStream({ originalMessages: messages }));
  35. 35 },
  36. 36 });
  37. 37 return createUIMessageStreamResponse({ stream });
  38. 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.

api/chat/route.ts
ts
  1. 1import {
  2. 2 createUIMessageStreamResponse,
  3. 3 createUIMessageStream,
  4. 4 streamText,
  5. 5 tool,
  6. 6 convertToModelMessages,
  7. 7 stepCountIs,
  8. 8} from 'ai-toolkit';
  9. 9import { z } from 'zod';
  10. 10export async function POST(req: Request) {
  11. 11 const { messages } = await req.json();
  12. 12 const stream = createUIMessageStream({
  13. 13 originalMessages: messages,
  14. 14 execute: async ({ writer }) => {
  15. 15 const result = streamText({
  16. 16 model: 'openai/gpt-4o',
  17. 17 messages: await convertToModelMessages(messages),
  18. 18 tools: {
  19. 19 getWeatherInformation: tool({
  20. 20 description: 'show the weather in a given city to the user',
  21. 21 inputSchema: z.object({ city: z.string() }),
  22. 22 outputSchema: z.string(),
  23. 23 // execute function removed to stop automatic execution
  24. 24 }),
  25. 25 },
  26. 26 stopWhen: stepCountIs(5),
  27. 27 });
  28. 28 writer.merge(result.toUIMessageStream({ originalMessages: messages })); // pass in original messages to avoid duplicate assistant messages
  29. 29 },
  30. 30 });
  31. 31 return createUIMessageStreamResponse({ stream });
  32. 32}
app/page.tsx
tsx
  1. 1'use client';
  2. 2import { useChat } from '@ai-toolkit/react';
  3. 3import {
  4. 4 DefaultChatTransport,
  5. 5 isStaticToolUIPart,
  6. 6 getStaticToolName,
  7. 7} from 'ai-toolkit';
  8. 8import { useState } from 'react';
  9. 9export default function Chat() {
  10. 10 const { messages, addToolOutput, sendMessage } = useChat({
  11. 11 transport: new DefaultChatTransport({
  12. 12 api: '/api/chat',
  13. 13 }),
  14. 14 });
  15. 15 const [input, setInput] = useState('');
  16. 16 return (
  17. 17 <div>
  18. 18 <div>
  19. 19 {messages?.map(m => (
  20. 20 <div key={m.id}>
  21. 21 <strong>{`${m.role}: `}</strong>
  22. 22 {m.parts?.map((part, i) => {
  23. 23 if (part.type === 'text') {
  24. 24 return <div key={i}>{part.text}</div>;
  25. 25 }
  26. 26 if (isStaticToolUIPart(part)) {
  27. 27 const toolName = getStaticToolName(part);
  28. 28 const toolCallId = part.toolCallId;
  29. 29 // render confirmation tool (client-side tool with user interaction)
  30. 30 if (
  31. 31 toolName === 'getWeatherInformation' &&
  32. 32 part.state === 'input-available'
  33. 33 ) {
  34. 34 return (
  35. 35 <div key={toolCallId}>
  36. 36 Get weather information for {part.input.city}?
  37. 37 <div>
  38. 38 <button
  39. 39 onClick={async () => {
  40. 40 await addToolOutput({
  41. 41 toolCallId,
  42. 42 tool: toolName,
  43. 43 output: 'Yes, confirmed.',
  44. 44 });
  45. 45 sendMessage();
  46. 46 }}
  47. 47 >
  48. 48 Yes
  49. 49 </button>
  50. 50 <button
  51. 51 onClick={async () => {
  52. 52 await addToolOutput({
  53. 53 toolCallId,
  54. 54 tool: toolName,
  55. 55 output: 'No, denied.',
  56. 56 });
  57. 57 sendMessage();
  58. 58 }}
  59. 59 >
  60. 60 No
  61. 61 </button>
  62. 62 </div>
  63. 63 </div>
  64. 64 );
  65. 65 }
  66. 66 }
  67. 67 })}
  68. 68 <br />
  69. 69 </div>
  70. 70 ))}
  71. 71 </div>
  72. 72 <form
  73. 73 onSubmit={e => {
  74. 74 e.preventDefault();
  75. 75 if (input.trim()) {
  76. 76 sendMessage({ text: input });
  77. 77 setInput('');
  78. 78 }
  79. 79 }}
  80. 80 >
  81. 81 <input
  82. 82 value={input}
  83. 83 placeholder="Say something..."
  84. 84 onChange={e => setInput(e.target.value)}
  85. 85 />
  86. 86 </form>
  87. 87 </div>
  88. 88 );
  89. 89}
api/chat/route.ts
ts
  1. 1import {
  2. 2 createUIMessageStreamResponse,
  3. 3 createUIMessageStream,
  4. 4 streamText,
  5. 5 tool,
  6. 6 convertToModelMessages,
  7. 7 stepCountIs,
  8. 8 isStaticToolUIPart,
  9. 9 getStaticToolName,
  10. 10 UIMessage,
  11. 11} from 'ai-toolkit';
  12. 12import { z } from 'zod';
  13. 13export async function POST(req: Request) {
  14. 14 const { messages }: { messages: UIMessage[] } = await req.json();
  15. 15 const stream = createUIMessageStream({
  16. 16 originalMessages: messages,
  17. 17 execute: async ({ writer }) => {
  18. 18 // pull out last message
  19. 19 const lastMessage = messages[messages.length - 1];
  20. 20 lastMessage.parts = await Promise.all(
  21. 21 // map through all message parts
  22. 22 lastMessage.parts?.map(async part => {
  23. 23 if (!isStaticToolUIPart(part)) {
  24. 24 return part;
  25. 25 }
  26. 26 const toolName = getStaticToolName(part);
  27. 27 // return if tool isn't weather tool or in a output-available state
  28. 28 if (
  29. 29 toolName !== 'getWeatherInformation' ||
  30. 30 part.state !== 'output-available'
  31. 31 ) {
  32. 32 return part;
  33. 33 }
  34. 34 // switch through tool output states (set on the frontend)
  35. 35 switch (part.output) {
  36. 36 case 'Yes, confirmed.': {
  37. 37 const result = await executeWeatherTool(part.input);
  38. 38 // forward updated tool result to the client:
  39. 39 writer.write({
  40. 40 type: 'tool-output-available',
  41. 41 toolCallId: part.toolCallId,
  42. 42 output: result,
  43. 43 });
  44. 44 // update the message part:
  45. 45 return { ...part, output: result };
  46. 46 }
  47. 47 case 'No, denied.': {
  48. 48 const result = 'Error: User denied access to weather information';
  49. 49 // forward updated tool result to the client:
  50. 50 writer.write({
  51. 51 type: 'tool-output-available',
  52. 52 toolCallId: part.toolCallId,
  53. 53 output: result,
  54. 54 });
  55. 55 // update the message part:
  56. 56 return { ...part, output: result };
  57. 57 }
  58. 58 default:
  59. 59 return part;
  60. 60 }
  61. 61 }) ?? [],
  62. 62 );
  63. 63 const result = streamText({
  64. 64 model: 'openai/gpt-4o',
  65. 65 messages: await convertToModelMessages(messages),
  66. 66 tools: {
  67. 67 getWeatherInformation: tool({
  68. 68 description: 'show the weather in a given city to the user',
  69. 69 inputSchema: z.object({ city: z.string() }),
  70. 70 outputSchema: z.string(),
  71. 71 }),
  72. 72 },
  73. 73 stopWhen: stepCountIs(5),
  74. 74 });
  75. 75 writer.merge(result.toUIMessageStream({ originalMessages: messages }));
  76. 76 },
  77. 77 });
  78. 78 return createUIMessageStreamResponse({ stream });
  79. 79}
  80. 80async function executeWeatherTool({ city }: { city: string }) {
  81. 81 const weatherOptions = ['sunny', 'cloudy', 'rainy', 'snowy'];
  82. 82 return weatherOptions[Math.floor(Math.random() * weatherOptions.length)];
  83. 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>

);

}

tools.ts
ts
  1. 1import { tool, ToolSet } from 'ai-toolkit';
  2. 2import { z } from 'zod';
  3. 3const getWeatherInformation = tool({
  4. 4 description: 'show the weather in a given city to the user',
  5. 5 inputSchema: z.object({ city: z.string() }),
  6. 6 outputSchema: z.string(), // must define outputSchema
  7. 7 // no execute function, we want human in the loop
  8. 8});
  9. 9const getLocalTime = tool({
  10. 10 description: 'get the local time for a specified location',
  11. 11 inputSchema: z.object({ location: z.string() }),
  12. 12 outputSchema: z.string(),
  13. 13 // including execute function -> no confirmation required
  14. 14 execute: async ({ location }) => {
  15. 15 console.log(`Getting local time for ${location}`);
  16. 16 return '10am';
  17. 17 },
  18. 18});
  19. 19export const tools = {
  20. 20 getWeatherInformation,
  21. 21 getLocalTime,
  22. 22} satisfies ToolSet;
types.ts
ts
  1. 1import { InferUITools, UIDataTypes, UIMessage } from 'ai-toolkit';
  2. 2import { tools } from './tools';
  3. 3export type MyTools = InferUITools<typeof tools>;
  4. 4// Define custom message type
  5. 5export type HumanInTheLoopUIMessage = UIMessage<
  6. 6 never, // metadata type
  7. 7 UIDataTypes, // data parts type
  8. 8 MyTools // tools type
  9. 9>;
utils.ts
ts
  1. 1import {
  2. 2 convertToModelMessages,
  3. 3 Tool,
  4. 4 ToolExecutionOptions,
  5. 5 ToolSet,
  6. 6 UIMessageStreamWriter,
  7. 7 getStaticToolName,
  8. 8 isStaticToolUIPart,
  9. 9} from 'ai-toolkit';
  10. 10import { HumanInTheLoopUIMessage } from './types';
  11. 11// Approval string to be shared across frontend and backend
  12. 12export const APPROVAL = {
  13. 13 YES: 'Yes, confirmed.',
  14. 14 NO: 'No, denied.',
  15. 15} as const;
  16. 16function isValidToolName<K extends PropertyKey, T extends object>(
  17. 17 key: K,
  18. 18 obj: T,
  19. 19): key is K & keyof T {
  20. 20 return key in obj;
  21. 21}
  22. 22/**
  23. 23 * Processes tool invocations where human input is required, executing tools when authorized.
  24. 24 *
  25. 25 * @param options - The function options
  26. 26 * @param options.tools - Map of tool names to Tool instances that may expose execute functions
  27. 27 * @param options.writer - UIMessageStream writer for sending results back to the client
  28. 28 * @param options.messages - Array of messages to process
  29. 29 * @param executionFunctions - Map of tool names to execute functions
  30. 30 * @returns Promise resolving to the processed messages
  31. 31 */
  32. 32export async function processToolCalls<
  33. 33 Tools extends ToolSet,
  34. 34 ExecutableTools extends {
  35. 35 [Tool in keyof Tools as Tools[Tool] extends { execute: Function }
  36. 36 ? never
  37. 37 : Tool]: Tools[Tool];
  38. 38 },
  39. 39>(
  40. 40 {
  41. 41 writer,
  42. 42 messages,
  43. 43 }: {
  44. 44 tools: Tools; // used for type inference
  45. 45 writer: UIMessageStreamWriter;
  46. 46 messages: HumanInTheLoopUIMessage[]; // IMPORTANT: replace with your message type
  47. 47 },
  48. 48 executeFunctions: {
  49. 49 [K in keyof Tools & keyof ExecutableTools]?: (
  50. 50 args: ExecutableTools[K] extends Tool<infer P> ? P : never,
  51. 51 context: ToolExecutionOptions,
  52. 52 ) => Promise<any>;
  53. 53 },
  54. 54): Promise<HumanInTheLoopUIMessage[]> {
  55. 55 const lastMessage = messages[messages.length - 1];
  56. 56 const parts = lastMessage.parts;
  57. 57 if (!parts) return messages;
  58. 58 const processedParts = await Promise.all(
  59. 59 parts.map(async part => {
  60. 60 // Only process tool invocations parts
  61. 61 if (!isStaticToolUIPart(part)) return part;
  62. 62 const toolName = getStaticToolName(part);
  63. 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. 64 if (!(toolName in executeFunctions) || part.state !== 'output-available')
  65. 65 return part;
  66. 66 let result;
  67. 67 if (part.output === APPROVAL.YES) {
  68. 68 // Get the tool and check if the tool has an execute function.
  69. 69 if (
  70. 70 !isValidToolName(toolName, executeFunctions) ||
  71. 71 part.state !== 'output-available'
  72. 72 ) {
  73. 73 return part;
  74. 74 }
  75. 75 const toolInstance = executeFunctions[toolName] as Tool['execute'];
  76. 76 if (toolInstance) {
  77. 77 result = await toolInstance(part.input, {
  78. 78 messages: await convertToModelMessages(messages),
  79. 79 toolCallId: part.toolCallId,
  80. 80 });
  81. 81 } else {
  82. 82 result = 'Error: No execute function found on tool';
  83. 83 }
  84. 84 } else if (part.output === APPROVAL.NO) {
  85. 85 result = 'Error: User denied access to tool execution';
  86. 86 } else {
  87. 87 // For any unhandled responses, return the original part.
  88. 88 return part;
  89. 89 }
  90. 90 // Forward updated tool result to the client.
  91. 91 writer.write({
  92. 92 type: 'tool-output-available',
  93. 93 toolCallId: part.toolCallId,
  94. 94 output: result,
  95. 95 });
  96. 96 // Return updated toolInvocation with the actual result.
  97. 97 return {
  98. 98 ...part,
  99. 99 output: result,
  100. 100 };
  101. 101 }),
  102. 102 );
  103. 103 // Finally return the processed messages
  104. 104 return [...messages.slice(0, -1), { ...lastMessage, parts: processedParts }];
  105. 105}
  106. 106export function getToolsRequiringConfirmation<T extends ToolSet>(
  107. 107 tools: T,
  108. 108): string[] {
  109. 109 return (Object.keys(tools) as (keyof T)[]).filter(key => {
  110. 110 const maybeTool = tools[key];
  111. 111 return typeof maybeTool.execute !== 'function';
  112. 112 }) as string[];
  113. 113}
app/api/chat/route.ts
ts
  1. 1import {
  2. 2 createUIMessageStreamResponse,
  3. 3 createUIMessageStream,
  4. 4 streamText,
  5. 5 convertToModelMessages,
  6. 6 stepCountIs,
  7. 7} from 'ai-toolkit';
  8. 8import { processToolCalls } from './utils';
  9. 9import { tools } from './tools';
  10. 10import { HumanInTheLoopUIMessage } from './types';
  11. 11// Allow streaming responses up to 30 seconds
  12. 12export const maxDuration = 30;
  13. 13export async function POST(req: Request) {
  14. 14 const { messages }: { messages: HumanInTheLoopUIMessage[] } =
  15. 15 await req.json();
  16. 16 const stream = createUIMessageStream({
  17. 17 originalMessages: messages,
  18. 18 execute: async ({ writer }) => {
  19. 19 // Utility function to handle tools that require human confirmation
  20. 20 // Checks for confirmation in last message and then runs associated tool
  21. 21 const processedMessages = await processToolCalls(
  22. 22 {
  23. 23 messages,
  24. 24 writer,
  25. 25 tools,
  26. 26 },
  27. 27 {
  28. 28 // type-safe object for tools without an execute function
  29. 29 getWeatherInformation: async ({ city }) => {
  30. 30 const conditions = ['sunny', 'cloudy', 'rainy', 'snowy'];
  31. 31 return `The weather in ${city} is ${
  32. 32 conditions[Math.floor(Math.random() * conditions.length)]
  33. 33 }.`;
  34. 34 },
  35. 35 },
  36. 36 );
  37. 37 const result = streamText({
  38. 38 model: 'openai/gpt-4o',
  39. 39 messages: convertToModelMessages(processedMessages),
  40. 40 tools,
  41. 41 stopWhen: stepCountIs(5),
  42. 42 });
  43. 43 writer.merge(
  44. 44 result.toUIMessageStream({ originalMessages: processedMessages }),
  45. 45 );
  46. 46 },
  47. 47 });
  48. 48 return createUIMessageStreamResponse({ stream });
  49. 49}
app/page.tsx
tsx
  1. 1'use client';
  2. 2import { useChat } from '@ai-toolkit/react';
  3. 3import {
  4. 4 DefaultChatTransport,
  5. 5 getStaticToolName,
  6. 6 isStaticToolUIPart,
  7. 7} from 'ai-toolkit';
  8. 8import { tools } from '../api/chat/tools';
  9. 9import { APPROVAL, getToolsRequiringConfirmation } from '../api/chat/utils';
  10. 10import { useState } from 'react';
  11. 11import { HumanInTheLoopUIMessage, MyTools } from '../api/chat/types';
  12. 12export default function Chat() {
  13. 13 const { messages, addToolOutput, sendMessage } =
  14. 14 useChat<HumanInTheLoopUIMessage>({
  15. 15 transport: new DefaultChatTransport({
  16. 16 api: '/api/chat',
  17. 17 }),
  18. 18 });
  19. 19 const [input, setInput] = useState('');
  20. 20 const toolsRequiringConfirmation = getToolsRequiringConfirmation(tools);
  21. 21 // used to disable input while confirmation is pending
  22. 22 const pendingToolCallConfirmation = messages.some(m =>
  23. 23 m.parts?.some(
  24. 24 part =>
  25. 25 isStaticToolUIPart(part) &&
  26. 26 part.state === 'input-available' &&
  27. 27 toolsRequiringConfirmation.includes(getStaticToolName(part)),
  28. 28 ),
  29. 29 );
  30. 30 return (
  31. 31 <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
  32. 32 {messages?.map(m => (
  33. 33 <div key={m.id} className="whitespace-pre-wrap">
  34. 34 <strong>{`${m.role}: `}</strong>
  35. 35 {m.parts?.map((part, i) => {
  36. 36 if (part.type === 'text') {
  37. 37 return <div key={i}>{part.text}</div>;
  38. 38 }
  39. 39 if (isStaticToolUIPart<MyTools>(part)) {
  40. 40 const toolName = getStaticToolName(part);
  41. 41 const toolCallId = part.toolCallId;
  42. 42 const dynamicInfoStyles = 'font-mono bg-zinc-100 p-1 text-sm';
  43. 43 // render confirmation tool (client-side tool with user interaction)
  44. 44 if (
  45. 45 toolsRequiringConfirmation.includes(toolName) &&
  46. 46 part.state === 'input-available'
  47. 47 ) {
  48. 48 return (
  49. 49 <div key={toolCallId}>
  50. 50 Run <span className={dynamicInfoStyles}>{toolName}</span>{' '}
  51. 51 with args: <br />
  52. 52 <span className={dynamicInfoStyles}>
  53. 53 {JSON.stringify(part.input, null, 2)}
  54. 54 </span>
  55. 55 <div className="flex gap-2 pt-2">
  56. 56 <button
  57. 57 className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
  58. 58 onClick={async () => {
  59. 59 await addToolOutput({
  60. 60 toolCallId,
  61. 61 tool: toolName,
  62. 62 output: APPROVAL.YES,
  63. 63 });
  64. 64 sendMessage();
  65. 65 }}
  66. 66 >
  67. 67 Yes
  68. 68 </button>
  69. 69 <button
  70. 70 className="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700"
  71. 71 onClick={async () => {
  72. 72 await addToolOutput({
  73. 73 toolCallId,
  74. 74 tool: toolName,
  75. 75 output: APPROVAL.NO,
  76. 76 });
  77. 77 sendMessage();
  78. 78 }}
  79. 79 >
  80. 80 No
  81. 81 </button>
  82. 82 </div>
  83. 83 </div>
  84. 84 );
  85. 85 }
  86. 86 }
  87. 87 })}
  88. 88 <br />
  89. 89 </div>
  90. 90 ))}
  91. 91 <form
  92. 92 onSubmit={e => {
  93. 93 e.preventDefault();
  94. 94 if (input.trim()) {
  95. 95 sendMessage({ text: input });
  96. 96 setInput('');
  97. 97 }
  98. 98 }}
  99. 99 >
  100. 100 <input
  101. 101 disabled={pendingToolCallConfirmation}
  102. 102 className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 rounded shadow-xl"
  103. 103 value={input}
  104. 104 placeholder="Say something..."
  105. 105 onChange={e => setInput(e.target.value)}
  106. 106 />
  107. 107 </form>
  108. 108 </div>
  109. 109 );
  110. 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.