Skip to content

Migrating from an approval SDK or a homegrown approval step

Many agents already have a human-in-the-loop step. It is often one of these:

  • An approval SDK with a decorator that holds a function call until a person answers.
  • A homegrown step: the agent posts a message to Slack or email, writes a row to a queue, and waits for someone to click Approve.

This page maps that step to Vulnify. The main difference: a typical approval step asks a person on every call to the wrapped function. Vulnify decides each call first. ALLOW runs it, REVIEW waits for a person, and BLOCK stops it. Which calls need a person is set by your policies and the risk engine. See Decisions.

Typical approval step Vulnify Notes
A client created with an API key Vulnify(api_key=...) (Python) or new Vulnify({ apiKey }) (Node.js) Vulnify reads the key you pass. Register the agent and resource names in the app.
A decorator or wrapper that holds the call vulnify.guard(fn, wait=..., **action) (Python) or vulnify.guard(action, fn, wait) (Node.js) You describe the action: agent, action, resource, destination, recordsAffected. With wait, the function runs only after approval. Python also has @vulnify.protect(**action), which does not wait: a REVIEW raises.
Every wrapped call goes to a person A policy with decision: REVIEW Calls that match the policy wait for a person. Others are allowed or blocked by policy or by the risk engine. See Policies.
Approval in Slack or by email Approval in the Vulnify app, with Approve and Deny in Slack, or from the review email See Reviews and Slack. Review emails go to owners and admins when they are on for the organization.
Routing to teams or individuals approverRoles on the policy: OWNER, ADMIN, MEMBER By role, not by named person.
Denial feedback passed to the model VulnifyBlockedError with the decision’s reasons After a denial while waiting, Node.js adds Review denied and Python adds Review block. The agent receives the reasons, not a reviewer’s free-text reply.
Ask a person a free-form question No equivalent See What Vulnify does not do.
Approval from an MCP client decide_action, then get_decision with waitMs On the Vulnify MCP server. A person approves in Vulnify, in Slack, or from the review email.

A sketch of the usual pattern. The decorator asks a person on every call and runs the function only after a yes. ask_a_person stands for your SDK call or your own Slack or queue code.

def require_approval(fn):
def wrapper(*args, **kwargs):
answer = ask_a_person(f"Run {fn.__name__} with {args} {kwargs}?") # blocks until someone answers
if answer.approved:
return fn(*args, **kwargs)
return f"Not approved: {answer.comment}"
return wrapper
@require_approval
def send_email(to: str, subject: str, body: str):
"""Send an email to the customer"""
...

The same tool, guarded by Vulnify. Your agent framework stays the same. On REVIEW, the call waits up to 5 minutes for a person to approve, then sends only if approved. On BLOCK, or a denial, the tool returns the reason to the model.

Terminal window
pip install vulnify
import os
from vulnify import Vulnify, VulnifyBlockedError
vulnify = Vulnify(api_key=os.environ["VULNIFY_API_KEY"])
COMPANY_DOMAIN = "@yourcompany.com"
def send_email(to: str, subject: str, body: str):
"""Send an email to the customer"""
...
def send_email_tool(to: str, subject: str, body: str) -> str:
"""The tool you give the model. Vulnify decides before the email is sent."""
try:
vulnify.guard(
lambda: send_email(to, subject, body),
wait={"timeout": 300, "poll": 2},
agent="OnboardingBot",
action="SEND_EMAIL",
resource="Customer Contacts",
destination="INTERNAL" if to.lower().endswith(COMPANY_DOMAIN) else "EXTERNAL_EMAIL",
records_affected=1,
)
return f"Email sent to {to}."
except VulnifyBlockedError as blocked:
return f"Email not sent. {blocked}"

Then add a policy so outbound email waits for a person. The format is on Policy files:

apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: review-external-customer-email
spec:
description: A person approves agent email to addresses outside the company
enabled: true
action: ANY
resource: ANY
condition:
action: SEND_EMAIL
destination: EXTERNAL
decision: REVIEW
mode: ENFORCE

To ask on every SEND_EMAIL call, drop destination from the condition. Every SEND_EMAIL then waits for a person.

On Node.js the same pattern is vulnify.guard(action, fn, { timeoutMs: 300_000 }). Adapters for LangChain, the OpenAI Agents SDK, the Vercel AI SDK, MCP servers, CrewAI, and LangGraph are on Framework adapters. A longer walkthrough is Stop an AI agent from exporting customer data.

If you rely on any of these, plan for it before you migrate.

  • No human-as-a-tool. An agent cannot ask a person a free-form question and get a text answer back. Vulnify only decides whether an action runs.
  • No Discord or SMS approvals. Reviews are answered in the Vulnify app, in Slack, or from the review email.
  • No free-text feedback to the model. A denial returns the decision’s reasons plus Review denied (Node.js) or Review block (Python). There are no custom response or reject options.
  • No routing to named people. Approvers are chosen by role on the policy.
  • No local CLI approval. The vulnify CLI validates, applies, and tests policies. It does not approve reviews.
  • No webhook when a review is resolved. Your code polls GET /v1/events/{id}, or uses wait in the SDK. See Reviews.
  • Setup per action. Agents and resources are registered in the app, and each guarded call describes its action. A bare decorator with no arguments is not enough.
  • Adapters for the listed frameworks only. The adapters cover the frameworks on Framework adapters. Anything else uses guard directly.