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, 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:
Create a new Node project, which will contain the agent, client, and server code:
mkdir ably-vercel-hitl-example && cd ably-vercel-hitl-example
npm init -yInstall the required packages using NPM:
npm install ai@^6 @ai-sdk/openai ably@^2 express jsonwebtoken zod@^4Export your API keys to the environment:
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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:bot-gum-big");
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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 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:
1
2
3
4
5
6
7
8
9
10
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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:
node server.mjsStep 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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:bot-gum-big");
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:
node client.mjsWith the server, client, and agent running, the workflow proceeds as follows:
- The agent sends a prompt to the model that triggers a tool call
- The agent publishes an approval request to the channel
- The client displays the request and prompts the user
- The user approves or rejects the request
- The agent verifies the approver's role meets the minimum requirement
- If approved and authorized, the agent executes the tool
Next steps
- Learn more about human-in-the-loop patterns and verification strategies
- Explore identifying users and agents for secure identity verification
- Understand sessions and identity in AI-enabled applications
- Learn about tool calls for agent-to-agent communication