diff --git a/src/data/nav/aitransport.ts b/src/data/nav/aitransport.ts index bfbf1e0b27..9638215b76 100644 --- a/src/data/nav/aitransport.ts +++ b/src/data/nav/aitransport.ts @@ -146,6 +146,10 @@ export default { name: 'Message per token', link: '/docs/guides/ai-transport/langgraph/lang-graph-message-per-token', }, + { + name: 'Human-in-the-loop', + link: '/docs/guides/ai-transport/langgraph/langgraph-human-in-the-loop', + }, ], }, { @@ -159,6 +163,10 @@ export default { name: 'Message per token', link: '/docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-token', }, + { + name: 'Human-in-the-loop', + link: '/docs/guides/ai-transport/vercel-ai-sdk/vercel-human-in-the-loop', + }, ], }, ], diff --git a/src/pages/docs/ai-transport/index.mdx b/src/pages/docs/ai-transport/index.mdx index 9b4e25aa0c..5c0402e257 100644 --- a/src/pages/docs/ai-transport/index.mdx +++ b/src/pages/docs/ai-transport/index.mdx @@ -88,6 +88,12 @@ Use the following guides to get started with the Vercel AI SDK: image: 'icon-tech-javascript', link: '/docs/guides/ai-transport/vercel-ai-sdk/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-ai-sdk/vercel-human-in-the-loop', + }, ]} @@ -109,6 +115,12 @@ Use the following guides to get started with LangGraph: image: 'icon-tech-javascript', link: '/docs/guides/ai-transport/langgraph/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/langgraph-human-in-the-loop', + }, ]} diff --git a/src/pages/docs/guides/ai-transport/langgraph/langgraph-human-in-the-loop.mdx b/src/pages/docs/guides/ai-transport/langgraph/langgraph-human-in-the-loop.mdx new file mode 100644 index 0000000000..037430f2ec --- /dev/null +++ b/src/pages/docs/guides/ai-transport/langgraph/langgraph-human-in-the-loop.mdx @@ -0,0 +1,466 @@ +--- +title: "Guide: Human-in-the-loop approval with LangGraph" +meta_description: "Implement human approval workflows for AI agent tool calls using LangGraph and Ably with role-based access control." +meta_keywords: "AI, human in the loop, HITL, LangGraph, LangChain, tool calling, approval workflow, AI transport, Ably, realtime, RBAC" +--- + +This guide shows you how to implement a human-in-the-loop (HITL) approval workflow for AI agent tool calls using LangGraph and Ably. The agent requests human approval before executing sensitive operations, with role-based access control to verify approvers have sufficient permissions. + +When the model calls a tool that requires human approval, the graph uses a custom tool node that handles the approval check before executing. Rather than using the standard `ToolNode` to execute tools automatically, this node publishes an `approval-request` message to an Ably channel, waits for an `approval-response` from a human approver, verifies the approver has the required role using [claims embedded in their JWT token](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims), and only then executes the action. + + + +## 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-example && cd ably-langgraph-hitl-example +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 zod +``` + + + + +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: Initialize the agent + +Set up the agent that will use LangGraph and request human approval for sensitive operations. This example uses a `publish_blog_post` tool that requires authorization before execution. + +Initialize the Ably client and create a channel for communication between the agent and human approvers. + +Add the following to a new file called `agent.mjs`: + + +```javascript +import { ChatAnthropic } from "@langchain/anthropic"; +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(); + +// Function that executes the approved action +async function publishBlogPost(args) { + const { title } = args; + console.log(`Publishing blog post: ${title}`); + // In production, this would call your CMS API + return { published: true, title }; +} +``` + + + + +Tools that modify data, access sensitive resources, or perform actions with business impact are good candidates for HITL approval workflows. + +## Step 2: Request human approval + +When the model returns a tool call, publish an approval request to the channel and wait for a human decision. The tool call ID is passed in the message headers to correlate requests with responses. + +Add the approval request function to `agent.mjs`: + + +```javascript +async function requestHumanApproval(toolCall) { + const approvalPromise = new Promise((resolve, reject) => { + pendingApprovals.set(toolCall.id, { toolCall, resolve, reject }); + }); + + await channel.publish({ + name: "approval-request", + data: { + tool: toolCall.name, + arguments: toolCall.args, + }, + extras: { + headers: { + toolCallId: toolCall.id, + }, + }, + }); + + console.log(`Approval request sent for: ${toolCall.name}`); + return approvalPromise; +} +``` + + +The `toolCall.id` provided by LangGraph correlates the approval request with the response, enabling the agent to handle multiple concurrent approval flows. + +## Step 3: Subscribe to approval responses + +Set up a subscription to receive approval decisions from human users. When a response arrives, verify the approver has sufficient permissions using role-based access control before resolving the pending promise. + +Add the subscription handler to `agent.mjs`: + + +```javascript +async function subscribeApprovalResponses() { + // Define role hierarchy from lowest to highest privilege + const roleHierarchy = ["editor", "publisher", "admin"]; + + // Define minimum role required for each tool + const approvalPolicies = { + publish_blog_post: { minRole: "publisher" }, + }; + + function canApprove(approverRole, requiredRole) { + const approverLevel = roleHierarchy.indexOf(approverRole); + const requiredLevel = roleHierarchy.indexOf(requiredRole); + return approverLevel >= requiredLevel; + } + + await channel.subscribe("approval-response", async (message) => { + const { decision } = message.data; + const toolCallId = message.extras?.headers?.toolCallId; + const pending = pendingApprovals.get(toolCallId); + + if (!pending) { + console.log(`No pending approval for tool call: ${toolCallId}`); + return; + } + + const policy = approvalPolicies[pending.toolCall.name]; + // Get the trusted role from the JWT user claim + const approverRole = message.extras?.userClaim; + + // Verify the approver's role meets the minimum required + if (!canApprove(approverRole, policy.minRole)) { + console.log(`Insufficient role: ${approverRole} < ${policy.minRole}`); + pending.reject( + new Error( + `Approver role '${approverRole}' insufficient for required '${policy.minRole}'` + ) + ); + pendingApprovals.delete(toolCallId); + return; + } + + // Process the decision + if (decision === "approved") { + console.log(`Approved by ${approverRole}`); + pending.resolve({ approved: true, approverRole }); + } else { + console.log(`Rejected by ${approverRole}`); + pending.reject(new Error(`Action rejected by ${approverRole}`)); + } + pendingApprovals.delete(toolCallId); + }); +} +``` + + +The `message.extras.userClaim` contains the role embedded in the approver's JWT token, providing a trusted source for authorization decisions. See [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for details on embedding claims in tokens. This ensures only users with sufficient privileges can approve sensitive operations. + +## Step 4: Process tool calls + +Create a function to process tool calls by requesting approval and executing the action if approved. + +Add the tool processing function to `agent.mjs`: + + +```javascript +async function processToolCall(toolCall) { + if (toolCall.name === "publish_blog_post") { + // requestHumanApproval returns a promise that resolves when the human + // approves the tool call, or rejects if the human explicitly rejects + // the tool call or the approver's role is insufficient. + await requestHumanApproval(toolCall); + return await publishBlogPost(toolCall.args); + } + throw new Error(`Unknown tool: ${toolCall.name}`); +} +``` + + +The function awaits approval before executing. If the approver rejects or has insufficient permissions, the promise rejects and the tool is not executed. + +## Step 5: Run the agent + +Create the LangGraph state graph that routes tool calls through the approval workflow. The graph uses a custom tool node instead of the standard `ToolNode` to intercept tool calls for approval. + +Add the agent runner to `agent.mjs`: + + +```javascript +// Initialize the model with tool definitions +const model = new ChatAnthropic({ + model: "claude-sonnet-4-5-20250929", +}).bindTools([ + { + name: "publish_blog_post", + description: "Publish a blog post to the website. Requires human approval.", + schema: z.object({ + title: z.string().describe("Title of the blog post to publish"), + }), + }, +]); + +// 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] }; +} + +// Custom tool node that handles approval before execution +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 { + const result = await processToolCall(toolCall); + toolResults.push({ + tool_call_id: toolCall.id, + type: "tool", + content: JSON.stringify(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(); + +async function runAgent(prompt) { + await subscribeApprovalResponses(); + + console.log(`User: ${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 'Introducing our new API'"); +``` + + +## Step 6: Create the authentication server + +The authentication server issues JWT tokens with embedded role claims. The role claim is trusted by Ably and included in messages, enabling secure role-based authorization. + +Add the following to a new file called `server.mjs`: + + +```javascript +import express from "express"; +import jwt from "jsonwebtoken"; + +const app = express(); + +// Mock authentication - replace with your actual auth logic +function authenticateUser(req, res, next) { + // In production, verify the user's session/credentials + req.user = { id: "user123", role: "publisher" }; + next(); +} + +// Return claims to embed in the JWT +function getJWTClaims(user) { + return { + "ably.channel.*": user.role, + }; +} + +app.get("/api/auth/token", authenticateUser, (req, res) => { + const [keyName, keySecret] = process.env.ABLY_API_KEY.split(":"); + + const token = jwt.sign(getJWTClaims(req.user), 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 `ably.channel.*` claim embeds the user's role in the JWT. When the user publishes messages, this claim is available as `message.extras.userClaim`, providing a trusted source for authorization. + +Run the server: + + +```shell +node server.mjs +``` + + +## Step 7: Create the approval client + +The approval client receives approval requests and allows humans to approve or reject them. It authenticates via the server to obtain a JWT with the user's role. + +Add the following to a new file called `client.mjs`: + + +```javascript +import Ably from "ably"; +import readline from "readline"; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +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"); + console.log("Waiting for approval requests...\n"); +}); + +const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + +await channel.subscribe("approval-request", (message) => { + const request = message.data; + + console.log("\n========================================"); + console.log("APPROVAL REQUEST"); + console.log("========================================"); + console.log(`Tool: ${request.tool}`); + console.log(`Arguments: ${JSON.stringify(request.arguments, null, 2)}`); + console.log("========================================"); + + 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: message.extras?.headers?.toolCallId, + }, + }, + }); + + console.log(`Decision sent: ${decision}\n`); + }); +}); +``` + + +Run the client in a separate terminal: + + +```shell +node client.mjs +``` + + +With the server, client, and agent running, the workflow proceeds as follows: + +1. The agent sends a prompt to the model that triggers a tool call +2. The agent publishes an approval request to the channel +3. The client displays the request and prompts the user +4. The user approves or rejects the request +5. The agent verifies the approver's role meets the minimum requirement +6. If approved and authorized, the agent executes the tool + +## Next steps + +- Learn more about [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) patterns and verification strategies +- Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for secure identity verification +- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications +- Learn about [tool calls](/docs/ai-transport/messaging/tool-calls) for agent-to-agent communication diff --git a/src/pages/docs/guides/ai-transport/vercel-ai-sdk/vercel-human-in-the-loop.mdx b/src/pages/docs/guides/ai-transport/vercel-ai-sdk/vercel-human-in-the-loop.mdx new file mode 100644 index 0000000000..198a045656 --- /dev/null +++ b/src/pages/docs/guides/ai-transport/vercel-ai-sdk/vercel-human-in-the-loop.mdx @@ -0,0 +1,414 @@ +--- +title: "Guide: Human-in-the-loop approval with Vercel AI SDK" +meta_description: "Implement human approval workflows for AI agent tool calls using the Vercel AI SDK and Ably with role-based access control." +meta_keywords: "AI, human in the loop, HITL, Vercel AI SDK, tool calling, approval workflow, AI transport, Ably, realtime, RBAC" +--- + +This guide shows you how to implement a human-in-the-loop (HITL) approval workflow for AI agent tool calls using the Vercel AI SDK and Ably. The agent requests human approval before executing sensitive operations, with role-based access control to verify approvers have sufficient permissions. + +When the model calls a tool that requires human approval, the agent intercepts the tool call, publishes an `approval-request` message to an Ably channel, waits for an `approval-response` from a human approver, verifies the approver has the required role using [claims embedded in their JWT token](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims), and only then executes the action. + + + +## Prerequisites + +To follow this guide, you need: +- Node.js 20 or higher +- An API key for your preferred model provider (such as OpenAI or Anthropic) +- 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-example && cd ably-vercel-hitl-example +npm init -y +``` + + +Install the required packages using NPM: + + +```shell +npm install ai@^6 @ai-sdk/openai ably@^2 express jsonwebtoken zod@^4 +``` + + + + +Export your API keys to the environment: + + +```shell +export OPENAI_API_KEY="your_openai_api_key_here" +export ABLY_API_KEY="your_ably_api_key_here" +``` + + +## Step 1: Initialize the agent + +Set up the agent that will use the Vercel AI SDK and request human approval for sensitive operations. This example uses a `publishBlogPost` tool that requires authorization before execution. + +Initialize the Ably client and create a channel for communication between the agent and human approvers. + +Add the following to a new file called `agent.mjs`: + + +```javascript +import { generateText, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; +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(); + +// Function that executes the approved action +async function publishBlogPost(args) { + const { title } = args; + console.log(`Publishing blog post: ${title}`); + // In production, this would call your CMS API + return { published: true, title }; +} +``` + + + + +Tools that modify data, access sensitive resources, or perform actions with business impact are good candidates for HITL approval workflows. + +## Step 2: Request human approval + +When the model returns a tool call, publish an approval request to the channel and wait for a human decision. The tool call ID is passed in the message headers to correlate requests with responses. + +Add the approval request function to `agent.mjs`: + + +```javascript +async function requestHumanApproval(toolCall) { + const approvalPromise = new Promise((resolve, reject) => { + pendingApprovals.set(toolCall.toolCallId, { toolCall, resolve, reject }); + }); + + await channel.publish({ + name: "approval-request", + data: { + tool: toolCall.toolName, + arguments: toolCall.input, + }, + extras: { + headers: { + toolCallId: toolCall.toolCallId, + }, + }, + }); + + console.log(`Approval request sent for: ${toolCall.toolName}`); + return approvalPromise; +} +``` + + +The `toolCall.toolCallId` provided by the Vercel AI SDK correlates the approval request with the response, enabling the agent to handle multiple concurrent approval flows. + +## Step 3: Subscribe to approval responses + +Set up a subscription to receive approval decisions from human users. When a response arrives, verify the approver has sufficient permissions using role-based access control before resolving the pending promise. + +Add the subscription handler to `agent.mjs`: + + +```javascript +async function subscribeApprovalResponses() { + // Define role hierarchy from lowest to highest privilege + const roleHierarchy = ["editor", "publisher", "admin"]; + + // Define minimum role required for each tool + const approvalPolicies = { + publishBlogPost: { minRole: "publisher" }, + }; + + function canApprove(approverRole, requiredRole) { + const approverLevel = roleHierarchy.indexOf(approverRole); + const requiredLevel = roleHierarchy.indexOf(requiredRole); + return approverLevel >= requiredLevel; + } + + await channel.subscribe("approval-response", async (message) => { + const { decision } = message.data; + const toolCallId = message.extras?.headers?.toolCallId; + const pending = pendingApprovals.get(toolCallId); + + if (!pending) { + console.log(`No pending approval for tool call: ${toolCallId}`); + return; + } + + const policy = approvalPolicies[pending.toolCall.toolName]; + // Get the trusted role from the JWT user claim + const approverRole = message.extras?.userClaim; + + // Verify the approver's role meets the minimum required + if (!canApprove(approverRole, policy.minRole)) { + console.log(`Insufficient role: ${approverRole} < ${policy.minRole}`); + pending.reject( + new Error( + `Approver role '${approverRole}' insufficient for required '${policy.minRole}'` + ) + ); + pendingApprovals.delete(toolCallId); + return; + } + + // Process the decision + if (decision === "approved") { + console.log(`Approved by ${approverRole}`); + pending.resolve({ approved: true, approverRole }); + } else { + console.log(`Rejected by ${approverRole}`); + pending.reject(new Error(`Action rejected by ${approverRole}`)); + } + pendingApprovals.delete(toolCallId); + }); +} +``` + + +The `message.extras.userClaim` contains the role embedded in the approver's JWT token, providing a trusted source for authorization decisions. See [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for details on embedding claims in tokens. This ensures only users with sufficient privileges can approve sensitive operations. + +## Step 4: Process tool calls + +Create a function to process tool calls by requesting approval and executing the action if approved. + +Add the tool processing function to `agent.mjs`: + + +```javascript +async function processToolCall(toolCall) { + if (toolCall.toolName === "publishBlogPost") { + // requestHumanApproval returns a promise that resolves when the human + // approves the tool call, or rejects if the human explicitly rejects + // the tool call or the approver's role is insufficient. + await requestHumanApproval(toolCall); + return await publishBlogPost(toolCall.input); + } + throw new Error(`Unknown tool: ${toolCall.toolName}`); +} +``` + + +The function awaits approval before executing. If the approver rejects or has insufficient permissions, the promise rejects and the tool is not executed. + +## Step 5: Run the agent + +Create the main agent function that sends a prompt to the model and processes any tool calls that require approval. The `publishBlogPost` tool includes a passthrough `execute` function since execution is handled manually via `processToolCall` after human approval. + +Add the agent runner to `agent.mjs`: + + +```javascript +async function runAgent(prompt) { + await subscribeApprovalResponses(); + + console.log(`User: ${prompt}`); + + const response = await generateText({ + model: openai.chat("gpt-4o"), + messages: [{ role: "user", content: prompt }], + tools: { + publishBlogPost: tool({ + description: "Publish a blog post to the website. Requires human approval.", + inputSchema: z.object({ + title: z.string().describe("Title of the blog post to publish"), + }), + execute: async (args) => { + return args; + }, + }), + }, + }); + + const toolCalls = response.toolCalls; + + for (const toolCall of toolCalls) { + console.log(`Tool call: ${toolCall.toolName}`); + try { + const result = await processToolCall(toolCall); + console.log("Result:", result); + } catch (err) { + console.error("Tool call failed:", err.message); + } + } + + realtime.close(); +} + +runAgent("Publish the blog post called 'Introducing our new API'"); +``` + + +## Step 6: Create the authentication server + +The authentication server issues JWT tokens with embedded role claims. The role claim is trusted by Ably and included in messages, enabling secure role-based authorization. + +Add the following to a new file called `server.mjs`: + + +```javascript +import express from "express"; +import jwt from "jsonwebtoken"; + +const app = express(); + +// Mock authentication - replace with your actual auth logic +function authenticateUser(req, res, next) { + // In production, verify the user's session/credentials + req.user = { id: "user123", role: "publisher" }; + next(); +} + +// Return claims to embed in the JWT +function getJWTClaims(user) { + return { + "ably.channel.*": user.role, + }; +} + +app.get("/api/auth/token", authenticateUser, (req, res) => { + const [keyName, keySecret] = process.env.ABLY_API_KEY.split(":"); + + const token = jwt.sign(getJWTClaims(req.user), 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 `ably.channel.*` claim embeds the user's role in the JWT. When the user publishes messages, this claim is available as `message.extras.userClaim`, providing a trusted source for authorization. + +Run the server: + + +```shell +node server.mjs +``` + + +## Step 7: Create the approval client + +The approval client receives approval requests and allows humans to approve or reject them. It authenticates via the server to obtain a JWT with the user's role. + +Add the following to a new file called `client.mjs`: + + +```javascript +import Ably from "ably"; +import readline from "readline"; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +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"); + console.log("Waiting for approval requests...\n"); +}); + +const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + +await channel.subscribe("approval-request", (message) => { + const request = message.data; + + console.log("\n========================================"); + console.log("APPROVAL REQUEST"); + console.log("========================================"); + console.log(`Tool: ${request.tool}`); + console.log(`Arguments: ${JSON.stringify(request.arguments, null, 2)}`); + console.log("========================================"); + + 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: message.extras?.headers?.toolCallId, + }, + }, + }); + + console.log(`Decision sent: ${decision}\n`); + }); +}); +``` + + +Run the client in a separate terminal: + + +```shell +node client.mjs +``` + + +With the server, client, and agent running, the workflow proceeds as follows: + +1. The agent sends a prompt to the model that triggers a tool call +2. The agent publishes an approval request to the channel +3. The client displays the request and prompts the user +4. The user approves or rejects the request +5. The agent verifies the approver's role meets the minimum requirement +6. If approved and authorized, the agent executes the tool + +## Next steps + +- Learn more about [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) patterns and verification strategies +- Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for secure identity verification +- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications +- Learn about [tool calls](/docs/ai-transport/messaging/tool-calls) for agent-to-agent communication