diff --git a/src/data/nav/aitransport.ts b/src/data/nav/aitransport.ts index 9e1567392f..9298d2da62 100644 --- a/src/data/nav/aitransport.ts +++ b/src/data/nav/aitransport.ts @@ -116,6 +116,14 @@ export default { name: 'LangGraph token streaming - message per response', link: '/docs/guides/ai-transport/lang-graph-message-per-response', }, + { + name: 'LangGraph human-in-the-loop', + link: '/docs/guides/ai-transport/langgraph-human-in-the-loop', + }, + { + name: 'Vercel AI SDK human-in-the-loop', + link: '/docs/guides/ai-transport/vercel-human-in-the-loop', + }, ], }, ], diff --git a/src/pages/docs/ai-transport/index.mdx b/src/pages/docs/ai-transport/index.mdx index 383264eafd..bc3f418aa6 100644 --- a/src/pages/docs/ai-transport/index.mdx +++ b/src/pages/docs/ai-transport/index.mdx @@ -76,6 +76,12 @@ Use the following guides to get started with the Vercel AI SDK: image: 'icon-tech-javascript', link: '/docs/guides/ai-transport/vercel-message-per-token', }, + { + title: 'Human-in-the-loop', + description: 'Implement HITL workflows with tool approval over Ably', + image: 'icon-tech-javascript', + link: '/docs/guides/ai-transport/vercel-human-in-the-loop', + }, ]} @@ -97,6 +103,12 @@ Use the following guides to get started with LangGraph: image: 'icon-tech-javascript', link: '/docs/guides/ai-transport/lang-graph-message-per-token', }, + { + title: 'Human-in-the-loop', + description: 'Implement HITL workflows with tool approval over Ably', + image: 'icon-tech-javascript', + link: '/docs/guides/ai-transport/langgraph-human-in-the-loop', + }, ]} diff --git a/src/pages/docs/guides/ai-transport/langgraph-human-in-the-loop.mdx b/src/pages/docs/guides/ai-transport/langgraph-human-in-the-loop.mdx new file mode 100644 index 0000000000..c6cd0496bd --- /dev/null +++ b/src/pages/docs/guides/ai-transport/langgraph-human-in-the-loop.mdx @@ -0,0 +1,571 @@ +--- +title: "Guide: Implement human-in-the-loop with LangGraph" +meta_description: "Implement human-in-the-loop workflows with LangGraph agents using Ably for real-time approval requests and role-based authorization." +meta_keywords: "AI, human in the loop, HITL, LangGraph, LangChain, tool calling, approval workflow, AI transport, Ably, realtime, JWT claims, authorization" +--- + +This guide shows you how to implement [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) (HITL) workflows with [LangGraph](https://docs.langchain.com/oss/javascript/langgraph/overview) agents using Ably for real-time approval requests and role-based authorization. Specifically, it demonstrates how to pause agent execution when sensitive tool calls are made, request human approval over Ably channels, and resume execution based on the decision. + +Using Ably to coordinate human-in-the-loop workflows enables real-time communication between AI agents and human approvers with reliable message delivery. This approach leverages Ably's [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for role-based authorization, ensuring only authorized users can approve specific actions. + + + +## Prerequisites + +To follow this guide, you need: +- Node.js 20 or higher +- An Anthropic API key +- An Ably API key + +Useful links: +- [LangGraph documentation](https://docs.langchain.com/oss/javascript/langgraph/overview) +- [LangGraph tool calling](https://js.langchain.com/docs/how_to/tool_calling) +- [Ably JavaScript SDK getting started](/docs/getting-started/javascript) + +Create a new NPM package, which will contain the agent, client, and server code: + + +```shell +mkdir ably-langgraph-hitl && cd ably-langgraph-hitl +npm init -y +``` + + +Install the required packages using NPM: + + +```shell +npm install @langchain/langgraph@^0.2 @langchain/anthropic@^0.3 @langchain/core@^0.3 ably@^2 express jsonwebtoken +``` + + + + +Export your API keys to the environment: + + +```shell +export ANTHROPIC_API_KEY="your_anthropic_api_key_here" +export ABLY_API_KEY="your_ably_api_key_here" +``` + + +## Step 1: Define a tool that requires approval + +Create a LangGraph agent with a tool that performs a sensitive action requiring human approval before execution. + +Create a new file `agent.mjs` with the following contents: + + +```javascript +import { ChatAnthropic } from "@langchain/anthropic"; +import { tool } from "@langchain/core/tools"; +import { StateGraph, Annotation, START, END } from "@langchain/langgraph"; +import { ToolNode } from "@langchain/langgraph/prebuilt"; +import * as z from "zod"; + +// Define a sensitive tool that requires human approval +const publishBlogPost = tool( + async ({ title }) => { + // This would publish the blog post in a real application + console.log(`Publishing blog post: ${title}`); + return `Successfully published blog post: ${title}`; + }, + { + name: "publish_blog_post", + description: "Publish a blog post to the website. This action requires human approval.", + schema: z.object({ + title: z.string().describe("Title that identifies the blog post in the CMS"), + }), + } +); + +const tools = [publishBlogPost]; +const toolNode = new ToolNode(tools); + +// Initialize the model with tools +const model = new ChatAnthropic({ + model: "claude-sonnet-4-5-20250929", +}).bindTools(tools); + +// Define state with message history +const StateAnnotation = Annotation.Root({ + messages: Annotation({ + reducer: (x, y) => x.concat(y), + default: () => [], + }), +}); + +// Agent node that calls the model +async function agent(state) { + const response = await model.invoke(state.messages); + return { messages: [response] }; +} + +// Determine next step based on tool calls +function shouldContinue(state) { + const lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage.tool_calls && lastMessage.tool_calls.length > 0) { + return "tools"; + } + return END; +} + +// Build and compile the graph +const graph = new StateGraph(StateAnnotation) + .addNode("agent", agent) + .addNode("tools", toolNode) + .addEdge(START, "agent") + .addConditionalEdges("agent", shouldContinue, ["tools", END]) + .addEdge("tools", "agent"); + +const app = graph.compile(); + +export { app, tools }; +``` + + +This creates a LangGraph agent with a `publish_blog_post` tool. In the next steps, you'll add human-in-the-loop approval before the tool executes. + +## Step 2: Set up Ably for approval requests + +Add Ably to the agent to publish approval requests when sensitive tool calls are detected. + +Update your `agent.mjs` file to include Ably and the approval flow: + + +```javascript +import { ChatAnthropic } from "@langchain/anthropic"; +import { tool } from "@langchain/core/tools"; +import { StateGraph, Annotation, START, END } from "@langchain/langgraph"; +import * as z from "zod"; +import Ably from "ably"; + +// Initialize Ably Realtime client +const realtime = new Ably.Realtime({ + key: process.env.ABLY_API_KEY, + echoMessages: false, +}); + +// Wait for connection to be established +await realtime.connection.once("connected"); + +// Create a channel for HITL communication +const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + +// Track pending approval requests +const pendingApprovals = new Map(); + +// Tools that require human approval +const toolsRequiringApproval = ["publish_blog_post"]; + +// Send a message to request approval for a tool call +async function requestHumanApproval(toolCall) { + const approvalPromise = new Promise((resolve, reject) => { + pendingApprovals.set(toolCall.id, { toolCall, resolve, reject }); + }); + + console.log("Sending approval request:", toolCall.name); + await channel.publish({ + name: "approval-request", + data: { + tool: toolCall.name, + arguments: toolCall.args, + }, + extras: { + headers: { + toolCallId: toolCall.id, + }, + }, + }); + + return approvalPromise; +} +``` + + + + +## Step 3: Process tool calls with approval + +Modify the agent to intercept tool calls that require approval and wait for a human decision before execution. + +Add the tool execution logic with approval handling to your `agent.mjs` file: + + +```javascript +// Mock function that would publish the specified blog post +async function executePublishBlogPost(args) { + console.log("Publishing blog post:", args.title); + return `Successfully published blog post: ${args.title}`; +} + +// Execute a tool call after receiving approval +async function executeToolCall(toolCall) { + switch (toolCall.name) { + case "publish_blog_post": + return await executePublishBlogPost(toolCall.args); + default: + throw new Error(`Unknown tool: ${toolCall.name}`); + } +} + +// Process a tool call, requesting approval if needed +async function processToolCall(toolCall) { + if (toolsRequiringApproval.includes(toolCall.name)) { + // Wait for human approval before executing + await requestHumanApproval(toolCall); + } + + // Execute the tool after approval (or immediately if no approval needed) + return await executeToolCall(toolCall); +} +``` + + +## Step 4: Subscribe to approval responses + +Set up a subscription to receive approval decisions from human approvers. Verify the approver's role using [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) embedded in the JWT. + +Add the approval response handling to your `agent.mjs` file: + + +```javascript +// Role hierarchy for authorization +const roleHierarchy = ["editor", "publisher", "admin"]; + +// Minimum role required to approve each tool +const approvalPolicies = { + publish_blog_post: "publisher", +}; + +// Check if the approver's role meets the minimum required role +function canApprove(approverRole, requiredRole) { + const approverLevel = roleHierarchy.indexOf(approverRole); + const requiredLevel = roleHierarchy.indexOf(requiredRole); + return approverLevel >= requiredLevel; +} + +// Subscribe to approval responses and resolve pending requests +async function subscribeApprovalResponses() { + await channel.subscribe("approval-response", async (message) => { + const response = message.data; + const toolCallId = message.extras?.headers?.toolCallId; + const pending = pendingApprovals.get(toolCallId); + + if (!pending) return; + + const requiredRole = approvalPolicies[pending.toolCall.name]; + + // Get the trusted role from the JWT claim + const approverRole = message.extras?.userClaim; + + // Verify the approver's role meets the minimum required role + if (!canApprove(approverRole, requiredRole)) { + pending.reject( + new Error(`Approver role '${approverRole}' insufficient for required '${requiredRole}'`) + ); + pendingApprovals.delete(toolCallId); + return; + } + + // Process the decision + if (response.decision === "approved") { + pending.resolve(`Action approved by user with role ${approverRole}`); + } else { + pending.reject(new Error(`Action rejected by user with role ${approverRole}`)); + } + + pendingApprovals.delete(toolCallId); + }); +} +``` + + + + +## Step 5: Run the agent with HITL + +Complete the agent by adding a function to run it with the human-in-the-loop workflow. + +Add the agent execution logic to your `agent.mjs` file: + + +```javascript +// Define state with message history +const StateAnnotation = Annotation.Root({ + messages: Annotation({ + reducer: (x, y) => x.concat(y), + default: () => [], + }), +}); + +// Initialize the model (without binding tools, we'll handle them manually) +const model = new ChatAnthropic({ + model: "claude-sonnet-4-5-20250929", +}).bindTools([ + { + name: "publish_blog_post", + description: "Publish a blog post to the website. This action requires human approval.", + schema: z.object({ + title: z.string().describe("Title that identifies the blog post in the CMS"), + }), + }, +]); + +// Agent node that calls the model +async function agent(state) { + const response = await model.invoke(state.messages); + return { messages: [response] }; +} + +// Custom tool node that handles approval +async function toolsWithApproval(state) { + const lastMessage = state.messages[state.messages.length - 1]; + const toolCalls = lastMessage.tool_calls || []; + const toolResults = []; + + for (const toolCall of toolCalls) { + try { + await processToolCall(toolCall); + const result = await executeToolCall(toolCall); + toolResults.push({ + tool_call_id: toolCall.id, + type: "tool", + content: result, + }); + } catch (error) { + toolResults.push({ + tool_call_id: toolCall.id, + type: "tool", + content: `Error: ${error.message}`, + }); + } + } + + return { messages: toolResults }; +} + +// Determine next step based on tool calls +function shouldContinue(state) { + const lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage.tool_calls && lastMessage.tool_calls.length > 0) { + return "tools"; + } + return END; +} + +// Build and compile the graph +const graph = new StateGraph(StateAnnotation) + .addNode("agent", agent) + .addNode("tools", toolsWithApproval) + .addEdge(START, "agent") + .addConditionalEdges("agent", shouldContinue, ["tools", END]) + .addEdge("tools", "agent"); + +const app = graph.compile(); + +// Run the agent +async function runAgent(prompt) { + await subscribeApprovalResponses(); + + console.log("Running agent with prompt:", prompt); + + const result = await app.invoke({ + messages: [{ role: "user", content: prompt }], + }); + + console.log("Agent completed. Final response:"); + const lastMessage = result.messages[result.messages.length - 1]; + console.log(lastMessage.content); + + realtime.close(); +} + +runAgent("Publish the blog post called 'Ably is awesome'"); +``` + + +## Step 6: Create the client for human approvers + +Create a client application that human approvers use to receive and respond to approval requests. + +Create a new file `client.mjs` with the following contents: + + +```javascript +import Ably from "ably"; +import readline from "readline"; + +// Set up readline for user input +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +// Initialize Ably with authentication +const realtime = new Ably.Realtime({ + authCallback: async (tokenParams, callback) => { + try { + const response = await fetch("http://localhost:3001/api/auth/token"); + const token = await response.text(); + callback(null, token); + } catch (error) { + callback(error, null); + } + }, +}); + +realtime.connection.on("connected", () => console.log("Connected to Ably")); + +// Create a channel for HITL communication +const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + +// Subscribe to approval requests and prompt for user decision +await channel.subscribe("approval-request", (message) => { + const request = message.data; + const toolCallId = message.extras?.headers?.toolCallId; + + console.log("\n--- Approval Request ---"); + console.log("Tool:", request.tool); + console.log("Arguments:", JSON.stringify(request.arguments, null, 2)); + + rl.question("Approve this action? (y/n): ", async (answer) => { + const decision = answer.toLowerCase() === "y" ? "approved" : "rejected"; + + await channel.publish({ + name: "approval-response", + data: { + decision, + }, + extras: { + headers: { + toolCallId, + }, + }, + }); + + console.log(`Decision sent: ${decision}`); + }); +}); + +console.log("Client ready. Waiting for approval requests..."); +``` + + +The client authenticates using a JWT that contains the user's role as a claim. When approval requests arrive, the user can approve or reject them, and the decision is published back to the channel. + + + +## Step 7: Set up the authentication server + +Create an authentication server that issues JWTs with role claims for approvers. + +Create a new file `server.mjs` with the following contents: + + +```javascript +import express from "express"; +import jwt from "jsonwebtoken"; + +const app = express(); + +// Mock authentication middleware +// Replace with your actual authentication logic +function authenticateUser(req, res, next) { + // In production, verify user credentials and look up their role + req.session = { userId: "user123", role: "publisher" }; + next(); +} + +// Return the claims payload to embed in the signed JWT +function getJWTClaims(userId, role) { + return { + "x-ably-clientId": userId, + "ably.channel.*": role, + }; +} + +// Auth endpoint for clients to obtain a signed JWT +app.get("/api/auth/token", authenticateUser, (req, res) => { + const [keyName, keySecret] = process.env.ABLY_API_KEY.split(":"); + + // Sign a JWT using the secret part of the Ably API key + const token = jwt.sign( + getJWTClaims(req.session.userId, req.session.role), + keySecret, + { + algorithm: "HS256", + keyid: keyName, + expiresIn: "1h", + } + ); + + res.type("application/jwt").send(token); +}); + +app.listen(3001, () => { + console.log("Auth server running on http://localhost:3001"); +}); +``` + + +The server embeds the user's role as a [user claim](/docs/auth/capabilities#custom-restrictions-on-channels-) in the JWT. The `ably.channel.*` claim makes the role available in message extras, allowing the agent to verify authorization. + + + +## Step 8: Test the HITL workflow + +Run all three components to test the complete human-in-the-loop workflow. + +Start the authentication server: + + +```shell +node server.mjs +``` + + +In a separate terminal, start the client: + + +```shell +node client.mjs +``` + + +In another terminal, run the agent: + + +```shell +node agent.mjs +``` + + +The workflow proceeds as follows: + +1. The agent receives the prompt and calls the `publish_blog_post` tool. +2. The agent publishes an approval request to the Ably channel. +3. The client receives the request and prompts the user for a decision. +4. The user approves or rejects the action. +5. The client publishes the decision with the user's role in the JWT claims. +6. The agent verifies the user's role and proceeds accordingly. + +## Next steps + +- Learn more about the [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) pattern used in this guide +- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications +- Explore [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for role-based authorization +- Learn about [LangGraph streaming](/docs/guides/ai-transport/lang-graph-message-per-response) for token-by-token delivery diff --git a/src/pages/docs/guides/ai-transport/vercel-human-in-the-loop.mdx b/src/pages/docs/guides/ai-transport/vercel-human-in-the-loop.mdx new file mode 100644 index 0000000000..c008bcee47 --- /dev/null +++ b/src/pages/docs/guides/ai-transport/vercel-human-in-the-loop.mdx @@ -0,0 +1,522 @@ +--- +title: "Guide: Implement human-in-the-loop with Vercel AI SDK" +meta_description: "Implement human-in-the-loop workflows with the Vercel AI SDK using Ably for real-time approval requests and role-based authorization." +meta_keywords: "AI, human in the loop, HITL, Vercel AI SDK, tool calling, approval workflow, AI transport, Ably, realtime, JWT claims, authorization" +--- + +This guide shows you how to implement [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) (HITL) workflows with the [Vercel AI SDK](https://ai-sdk.dev/docs) using Ably for real-time approval requests and role-based authorization. Specifically, it demonstrates how to pause tool execution when sensitive actions are requested, route approval requests to human approvers over Ably channels, and proceed based on the decision. + +Using Ably to coordinate human-in-the-loop workflows enables real-time communication between AI agents and human approvers with reliable message delivery. This approach leverages Ably's [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for role-based authorization, ensuring only authorized users can approve specific actions. + + + +## Prerequisites + +To follow this guide, you need: +- Node.js 20 or higher +- A Vercel AI Gateway API key (or an API key for your preferred provider) +- An Ably API key + +Useful links: +- [Vercel AI SDK documentation](https://ai-sdk.dev/docs) +- [Vercel AI SDK tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) +- [Ably JavaScript SDK getting started](/docs/getting-started/javascript) + +Create a new NPM package, which will contain the agent, client, and server code: + + +```shell +mkdir ably-vercel-hitl && cd ably-vercel-hitl +npm init -y +``` + + +Install the required packages using NPM: + + +```shell +npm install ai@^6 ably@^2 express jsonwebtoken zod +``` + + + + +Export your API keys to the environment: + + +```shell +export AI_GATEWAY_API_KEY="your_ai_gateway_api_key_here" +export ABLY_API_KEY="your_ably_api_key_here" +``` + + +## Step 1: Define tools with approval requirements + +Create an agent with tools that require human approval before execution. The Vercel AI SDK allows you to define tools and control their execution flow. + +Create a new file `agent.mjs` with the following contents: + + +```javascript +import { generateText, tool } from "ai"; +import { z } from "zod"; + +// Define tools - some require approval, others don't +const tools = { + // This tool executes automatically (no approval needed) + getDraftPosts: tool({ + description: "Get a list of draft blog posts from the CMS", + parameters: z.object({}), + execute: async () => { + return ["Draft 1: Introduction to AI", "Draft 2: Ably is awesome"]; + }, + }), + + // This tool requires human approval (no execute function) + publishBlogPost: tool({ + description: "Publish a blog post to the website. This action requires human approval.", + parameters: z.object({ + title: z.string().describe("Title that identifies the blog post in the CMS"), + }), + // No execute function - requires manual handling + }), +}; + +// Track which tools require approval +const toolsRequiringApproval = ["publishBlogPost"]; + +export { tools, toolsRequiringApproval }; +``` + + +Tools without an `execute` function will not run automatically. Instead, they return their parameters, allowing you to implement approval logic before execution. + +## Step 2: Set up Ably for approval requests + +Add Ably to the agent to publish approval requests when sensitive tool calls are detected. + +Update your `agent.mjs` file to include Ably and the approval flow: + + +```javascript +import { generateText, tool } from "ai"; +import { z } from "zod"; +import Ably from "ably"; + +// Initialize Ably Realtime client +const realtime = new Ably.Realtime({ + key: process.env.ABLY_API_KEY, + echoMessages: false, +}); + +// Wait for connection to be established +await realtime.connection.once("connected"); + +// Create a channel for HITL communication +const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + +// Track pending approval requests +const pendingApprovals = new Map(); + +// Define tools +const tools = { + getDraftPosts: tool({ + description: "Get a list of draft blog posts from the CMS", + parameters: z.object({}), + execute: async () => { + return ["Draft 1: Introduction to AI", "Draft 2: Ably is awesome"]; + }, + }), + + publishBlogPost: tool({ + description: "Publish a blog post to the website. This action requires human approval.", + parameters: z.object({ + title: z.string().describe("Title that identifies the blog post in the CMS"), + }), + }), +}; + +// Track which tools require approval +const toolsRequiringApproval = ["publishBlogPost"]; + +// Send a message to request approval for a tool call +async function requestHumanApproval(toolCall) { + const approvalPromise = new Promise((resolve, reject) => { + pendingApprovals.set(toolCall.toolCallId, { toolCall, resolve, reject }); + }); + + console.log("Sending approval request:", toolCall.toolName); + await channel.publish({ + name: "approval-request", + data: { + tool: toolCall.toolName, + arguments: toolCall.args, + }, + extras: { + headers: { + toolCallId: toolCall.toolCallId, + }, + }, + }); + + return approvalPromise; +} +``` + + + + +## Step 3: Subscribe to approval responses + +Set up a subscription to receive approval decisions from human approvers. Verify the approver's role using [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) embedded in the JWT. + +Add the approval response handling to your `agent.mjs` file: + + +```javascript +// Role hierarchy for authorization +const roleHierarchy = ["editor", "publisher", "admin"]; + +// Minimum role required to approve each tool +const approvalPolicies = { + publishBlogPost: "publisher", +}; + +// Check if the approver's role meets the minimum required role +function canApprove(approverRole, requiredRole) { + const approverLevel = roleHierarchy.indexOf(approverRole); + const requiredLevel = roleHierarchy.indexOf(requiredRole); + return approverLevel >= requiredLevel; +} + +// Subscribe to approval responses and resolve pending requests +async function subscribeApprovalResponses() { + await channel.subscribe("approval-response", async (message) => { + const response = message.data; + const toolCallId = message.extras?.headers?.toolCallId; + const pending = pendingApprovals.get(toolCallId); + + if (!pending) return; + + const requiredRole = approvalPolicies[pending.toolCall.toolName]; + + // Get the trusted role from the JWT claim + const approverRole = message.extras?.userClaim; + + // Verify the approver's role meets the minimum required role + if (!canApprove(approverRole, requiredRole)) { + pending.reject( + new Error(`Approver role '${approverRole}' insufficient for required '${requiredRole}'`) + ); + pendingApprovals.delete(toolCallId); + return; + } + + // Process the decision + if (response.decision === "approved") { + pending.resolve(`Action approved by user with role ${approverRole}`); + } else { + pending.reject(new Error(`Action rejected by user with role ${approverRole}`)); + } + + pendingApprovals.delete(toolCallId); + }); +} +``` + + + + +## Step 4: Execute tools with approval + +Add logic to execute tools that require approval. After receiving approval, execute the actual tool logic. + +Add the tool execution logic to your `agent.mjs` file: + + +```javascript +// Execute the actual tool logic after approval +async function executeToolAfterApproval(toolCall) { + switch (toolCall.toolName) { + case "publishBlogPost": + console.log("Publishing blog post:", toolCall.args.title); + return `Successfully published blog post: ${toolCall.args.title}`; + default: + throw new Error(`Unknown tool: ${toolCall.toolName}`); + } +} + +// Process tool calls that require approval +async function processToolCallsWithApproval(toolCalls) { + const toolResults = []; + + for (const toolCall of toolCalls) { + if (toolsRequiringApproval.includes(toolCall.toolName)) { + try { + // Request and wait for approval + await requestHumanApproval(toolCall); + + // Execute the tool after approval + const result = await executeToolAfterApproval(toolCall); + toolResults.push({ + toolCallId: toolCall.toolCallId, + result, + }); + } catch (error) { + toolResults.push({ + toolCallId: toolCall.toolCallId, + result: `Error: ${error.message}`, + }); + } + } + } + + return toolResults; +} +``` + + +## Step 5: Run the agent with HITL + +Complete the agent by adding a function to run it with the human-in-the-loop workflow. The agent uses a loop to handle multi-step tool calling with approval. + +Add the agent execution logic to your `agent.mjs` file: + + +```javascript +async function runAgent(prompt) { + await subscribeApprovalResponses(); + + console.log("Running agent with prompt:", prompt); + + let messages = [{ role: "user", content: prompt }]; + let continueLoop = true; + + while (continueLoop) { + const response = await generateText({ + model: "openai/gpt-4o", + messages, + tools, + maxSteps: 1, // Process one step at a time for HITL + }); + + // Check for tool calls that need approval + const pendingToolCalls = response.steps + .flatMap((step) => step.toolCalls) + .filter((tc) => toolsRequiringApproval.includes(tc.toolName)); + + if (pendingToolCalls.length > 0) { + // Process tools requiring approval + const toolResults = await processToolCallsWithApproval(pendingToolCalls); + + // Add the tool results to the conversation + messages = [ + ...messages, + { role: "assistant", content: response.text, toolCalls: pendingToolCalls }, + ...toolResults.map((tr) => ({ + role: "tool", + toolCallId: tr.toolCallId, + content: tr.result, + })), + ]; + } else if (response.finishReason === "tool-calls") { + // Automatic tools were called, continue the loop + messages = response.messages; + } else { + // No more tool calls, we're done + continueLoop = false; + console.log("\nAgent completed. Final response:"); + console.log(response.text); + } + } + + realtime.close(); +} + +runAgent("Publish the blog post called 'Ably is awesome'"); +``` + + +## Step 6: Create the client for human approvers + +Create a client application that human approvers use to receive and respond to approval requests. + +Create a new file `client.mjs` with the following contents: + + +```javascript +import Ably from "ably"; +import readline from "readline"; + +// Set up readline for user input +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +// Initialize Ably with authentication +const realtime = new Ably.Realtime({ + authCallback: async (tokenParams, callback) => { + try { + const response = await fetch("http://localhost:3001/api/auth/token"); + const token = await response.text(); + callback(null, token); + } catch (error) { + callback(error, null); + } + }, +}); + +realtime.connection.on("connected", () => console.log("Connected to Ably")); + +// Create a channel for HITL communication +const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + +// Subscribe to approval requests and prompt for user decision +await channel.subscribe("approval-request", (message) => { + const request = message.data; + const toolCallId = message.extras?.headers?.toolCallId; + + console.log("\n--- Approval Request ---"); + console.log("Tool:", request.tool); + console.log("Arguments:", JSON.stringify(request.arguments, null, 2)); + + rl.question("Approve this action? (y/n): ", async (answer) => { + const decision = answer.toLowerCase() === "y" ? "approved" : "rejected"; + + await channel.publish({ + name: "approval-response", + data: { + decision, + }, + extras: { + headers: { + toolCallId, + }, + }, + }); + + console.log(`Decision sent: ${decision}`); + }); +}); + +console.log("Client ready. Waiting for approval requests..."); +``` + + +The client authenticates using a JWT that contains the user's role as a claim. When approval requests arrive, the user can approve or reject them, and the decision is published back to the channel. + + + +## Step 7: Set up the authentication server + +Create an authentication server that issues JWTs with role claims for approvers. + +Create a new file `server.mjs` with the following contents: + + +```javascript +import express from "express"; +import jwt from "jsonwebtoken"; + +const app = express(); + +// Mock authentication middleware +// Replace with your actual authentication logic +function authenticateUser(req, res, next) { + // In production, verify user credentials and look up their role + req.session = { userId: "user123", role: "publisher" }; + next(); +} + +// Return the claims payload to embed in the signed JWT +function getJWTClaims(userId, role) { + return { + "x-ably-clientId": userId, + "ably.channel.*": role, + }; +} + +// Auth endpoint for clients to obtain a signed JWT +app.get("/api/auth/token", authenticateUser, (req, res) => { + const [keyName, keySecret] = process.env.ABLY_API_KEY.split(":"); + + // Sign a JWT using the secret part of the Ably API key + const token = jwt.sign( + getJWTClaims(req.session.userId, req.session.role), + keySecret, + { + algorithm: "HS256", + keyid: keyName, + expiresIn: "1h", + } + ); + + res.type("application/jwt").send(token); +}); + +app.listen(3001, () => { + console.log("Auth server running on http://localhost:3001"); +}); +``` + + +The server embeds the user's role as a [user claim](/docs/auth/capabilities#custom-restrictions-on-channels-) in the JWT. The `ably.channel.*` claim makes the role available in message extras, allowing the agent to verify authorization. + + + +## Step 8: Test the HITL workflow + +Run all three components to test the complete human-in-the-loop workflow. + +Start the authentication server: + + +```shell +node server.mjs +``` + + +In a separate terminal, start the client: + + +```shell +node client.mjs +``` + + +In another terminal, run the agent: + + +```shell +node agent.mjs +``` + + +The workflow proceeds as follows: + +1. The agent receives the prompt and determines it needs to call the `publishBlogPost` tool. +2. The agent publishes an approval request to the Ably channel. +3. The client receives the request and prompts the user for a decision. +4. The user approves or rejects the action. +5. The client publishes the decision with the user's role in the JWT claims. +6. The agent verifies the user's role and proceeds accordingly. + +## Next steps + +- Learn more about the [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) pattern used in this guide +- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications +- Explore [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for role-based authorization +- Learn about [Vercel AI SDK streaming](/docs/guides/ai-transport/vercel-message-per-response) for token-by-token delivery