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.
How it works
Section titled “How it works”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.
pip install vulnify langgraph langchain1. Guard the tool node
Section titled “1. Guard the tool node”import os
from langchain_core.tools import toolfrom langgraph.graph import END, START, MessagesState, StateGraphfrom langgraph.prebuilt import ToolNode, tools_conditionfrom vulnify import Vulnifyfrom vulnify.adapters import langgraph_tool_guard
vulnify = Vulnify(api_key=os.environ["VULNIFY_API_KEY"])
@tooldef lookup_ticket(ticket_id: str) -> str: """Read one support ticket.""" return f"{ticket_id}: customer asks for a refund"
@tooldef 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}"
@tooldef 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.
2. Add the policy
Section titled “2. Add the policy”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/v1kind: Policymetadata: name: block-ticket-deletesspec: description: Agents never delete support tickets enabled: true action: DELETE resource: ANY decision: BLOCK mode: ENFORCE---apiVersion: vulnify.io/v1kind: Policymetadata: name: review-external-exportsspec: 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 - ADMINSave it as vulnify/policies/support-agent.yaml, then check and apply it with the CLI:
npx -p @vulnify/sdk vulnify policies validatenpx -p @vulnify/sdk vulnify policies apply --dry-runnpx -p @vulnify/sdk vulnify policies applyapply needs an org-wide LIVE key. You can also create the same rules in the app under Policies.
3. What the agent sees
Section titled “3. What the agent sees”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: REVIEWfinalDecision: REVIEWriskLevel: HIGHriskScore: 65reasons: Export operation; External destination; Large data volume (800 records); Policy matched: review-external-exportspolicy: { 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_decisionbecomesALLOWand the export runs. The tool message is the tool’s normal result, heresent 800 tickets to partner@example.com. The storeddecisionstaysREVIEW, andreview.statusisAPPROVED. - 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 asReview 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.
Use it with create_agent
Section titled “Use it with create_agent”The same guard works as middleware on LangChain’s create_agent:
from langchain.agents import create_agentfrom langchain.agents.middleware import wrap_tool_call
agent = create_agent(model, tools=tools, middleware=[wrap_tool_call(guard)])Next steps
Section titled “Next steps”- Run a first decision end to end in the Quickstart.
- Other frameworks: Framework adapters.
- The same rules without a framework: Stop an AI agent from deleting production data and Stop an AI agent from exporting customer data.
- Create the policy and an API key in the Vulnify app.

