Skip to content

Human approval for LangGraph tool calls

A LangGraph agent picks its own tools. Most calls are harmless reads. A few delete records or send data out of the company, and those are the ones a person should see first.

LangGraph’s interrupt() can pause a graph, but then you write the bookkeeping yourself: which calls pause, who may resume them, where the answer is recorded. With several tool calls in one step, that gets hard to follow.

This guide puts a Vulnify check in front of the tool node instead. The graph does not change shape. Before a listed tool runs, Vulnify answers ALLOW, REVIEW, or BLOCK. Reads run unchanged. A delete is blocked. An export to an outside address waits for an approver, then runs or not.

langgraph_tool_guard from the Python SDK is a wrap_tool_call for LangGraph’s ToolNode. For each tool you list, it turns the tool call’s arguments into an action (agent, action, resource, destination, records) and asks Vulnify. The tool runs only when final_decision is ALLOW. Otherwise the model gets an error tool message with Vulnify’s reasons, and the tool does not run. Tools you do not list run as before. See Framework adapters.

You need an API key from app.vulnify.io. Use a TEST key while you try this. Register the agent (SupportBot) and the resource (Support Tickets, classified Internal) in the app under those names, or change the names in the code. An unknown agent or resource raises an error. Give SupportBot permission for the actions it uses on that resource (here EXPORT_DATA). When the organization setting block actions an agent has no permission for is on, a missing permission blocks the action even when a policy says REVIEW: the reasons then start with Missing permission: …. See Agents.

Terminal window
pip install vulnify langgraph langchain
import os
from langchain_core.tools import tool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from vulnify import Vulnify
from vulnify.adapters import langgraph_tool_guard
vulnify = Vulnify(api_key=os.environ["VULNIFY_API_KEY"])
@tool
def lookup_ticket(ticket_id: str) -> str:
"""Read one support ticket."""
return f"{ticket_id}: customer asks for a refund"
@tool
def delete_ticket(ticket_id: str) -> str:
"""Delete a support ticket."""
# Replace with your real delete. It runs only after ALLOW.
return f"deleted {ticket_id}"
@tool
def export_tickets(email: str, rows: int) -> str:
"""Email a CSV of support tickets."""
# Replace with your real export. It runs only after ALLOW.
return f"sent {rows} tickets to {email}"
def describe_delete(args: dict) -> dict:
return {
"agent": "SupportBot",
"action": "DELETE_DATA",
"resource": "Support Tickets",
"records_affected": 1,
}
def describe_export(args: dict) -> dict:
email = str(args.get("email", ""))
return {
"agent": "SupportBot",
"action": "EXPORT_DATA",
"resource": "Support Tickets",
"destination": "INTERNAL" if email.endswith("@yourcompany.com") else "EXTERNAL_EMAIL",
"records_affected": int(args.get("rows", 0)),
}
guard = langgraph_tool_guard(
vulnify,
{"delete_ticket": describe_delete, "export_tickets": describe_export},
wait={"timeout": 120}, # on REVIEW, wait up to 2 minutes for a person
)
tools = [lookup_ticket, delete_ticket, export_tickets]
def build_graph(model):
model_with_tools = model.bind_tools(tools)
def call_model(state: MessagesState):
return {"messages": [model_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools, wrap_tool_call=guard))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition, ["tools", END])
builder.add_edge("tools", "agent")
return builder.compile()

Pass your chat model to build_graph() and invoke the graph as usual. lookup_ticket is not in the map, so it never calls Vulnify.

The describe functions are where you tell Vulnify what the call really does. Compute the destination and the record count from the arguments, as describe_export does. Policies match on them.

For an async graph, pass awrap_tool_call=alanggraph_tool_guard(...) to ToolNode. It takes the same arguments.

Two rules: deletes of support tickets are blocked, and exports that leave the company wait for a person. action: DELETE and action: EXPORT are action families. The event actions DELETE_DATA and EXPORT_DATA are what the code sends. The format is on Policy files.

apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: block-ticket-deletes
spec:
description: Agents never delete support tickets
enabled: true
action: DELETE
resource: ANY
decision: BLOCK
mode: ENFORCE
---
apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: review-external-exports
spec:
description: A person approves any export that leaves the company
enabled: true
action: EXPORT
resource: ANY
condition:
destination: EXTERNAL
decision: REVIEW
mode: ENFORCE
approverRoles:
- OWNER
- ADMIN

Save it as vulnify/policies/support-agent.yaml, then check and apply it with the CLI:

Terminal window
npx -p @vulnify/sdk vulnify policies validate
npx -p @vulnify/sdk vulnify policies apply --dry-run
npx -p @vulnify/sdk vulnify policies apply

apply needs an org-wide LIVE key. You can also create the same rules in the app under Policies.

A read. lookup_ticket runs. No call to Vulnify, no event.

A delete. Vulnify returns BLOCK. The tool does not run. The tool node returns an error tool message to the model, which can tell the user why:

Vulnify BLOCK: <reasons from Vulnify>

An export to an outside address. Vulnify returns REVIEW and opens a review. Measured on a test workspace for 800 rows to partner@example.com:

decision: REVIEW
finalDecision: REVIEW
riskLevel: HIGH
riskScore: 65
reasons: Export operation; External destination; Large data volume (800 records);
Policy matched: review-external-exports
policy: { id, name: "review-external-exports" }

Because the guard was created with wait={"timeout": 120}, the tool node waits while an approver decides in the app, from the Slack app, or from the review email when review emails are on. See Reviews.

  • Approved: final_decision becomes ALLOW and the export runs. The tool message is the tool’s normal result, here sent 800 tickets to partner@example.com. The stored decision stays REVIEW, and review.status is APPROVED.
  • Denied, expired, or no answer within 120 seconds: the export does not run, and the model gets Vulnify REVIEW: … with the reasons and the outcome appended, such as Review timeout.

Without wait, a REVIEW returns that message right away and the export does not run. Use that when your agent should move on and let the person decide later.

A user whose role is in the policy’s approverRoles (here Owner or Admin) approves or denies. The usual review rules apply, including an authenticator code for HIGH or CRITICAL approvals when MFA is on for the approver’s account.

If Vulnify cannot be reached, the SDK fails closed by default: the result is BLOCK with degraded set, and the tool does not run. See Decisions.

Every decision is appended to the audit log, including the blocked ones.

The same guard works as middleware on LangChain’s create_agent:

from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
agent = create_agent(model, tools=tools, middleware=[wrap_tool_call(guard)])