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