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, 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:
Create a new Node project, which will contain the agent, client, and server code:
mkdir ably-langgraph-hitl-example && cd ably-langgraph-hitl-example
npm init -yInstall the required packages using NPM:
npm install @langchain/langgraph@^0.2 @langchain/anthropic@^0.3 @langchain/core@^0.3 ably@^2 express jsonwebtoken zodExport your API keys to the environment:
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:
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 { 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: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.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:
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 = {
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 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.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:
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 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:
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