Human approval for Claude actions with the Vulnify connector
Claude can send email, share files, and write to other tools through its connectors. Some of those actions should wait for a person: an export to an outside address, a message to a customer, a delete.
With the Vulnify connector, Claude asks Vulnify before such an action. Vulnify answers ALLOW, REVIEW, or BLOCK and records the decision. On REVIEW, Claude holds the action and tells you it is waiting for approval. A person approves or denies in Vulnify, and Claude reads the result before it goes on.
Vulnify is available in Claude’s connector directory: Connect to Claude → Select Connect on the listing, then sign in with Vulnify.
How it works
Section titled “How it works”The connector is the hosted Vulnify MCP server at https://mcp.vulnify.io/mcp. After you sign in, Claude has five tools. Two matter here:
decide_actionrecords a real decision for the action Claude is about to take (POST /v1/events). It writes a security event and an audit entry, and it can open a review.get_decisionreads that decision again (GET /v1/events/{id}). WithwaitMsit polls aREVIEWuntil it becomesALLOWorBLOCK, for up to 30 seconds per call.
finalDecision is the result that counts. ALLOW means proceed. REVIEW means hold the action until a person decides. BLOCK means skip it.
The connector has no approve tool and no deny tool, on purpose. Claude cannot approve its own request. A person resolves the review in Vulnify.
1. Connect Vulnify in Claude
Section titled “1. Connect Vulnify in Claude”On a personal account, open Customize > Connectors, then the Discover tab. Search for Vulnify, open the listing, and select Connect. Sign in with Vulnify, choose the organization, and allow the scopes.
On Team and Enterprise plans, an Owner or Primary Owner adds Vulnify under Organization settings > Connectors. Each member then connects with their own Vulnify account.
The scopes map to the tools:
| Scope | Tools |
|---|---|
decisions:write |
decide_action |
decisions:read |
get_decision |
policies:read |
list_policies |
policies:test |
check_action, test_policies |
For this guide, allow at least decisions:write and decisions:read. Other ways to connect, including a custom connector by URL, are on Vulnify MCP server.
2. Add the policy
Section titled “2. Add the policy”Claude describes each action with an agent name (for example claude), the action, the resource, the destination, and the record count. Register the agent and the resources Claude works with in the app, under the names Claude will use. An unknown agent or resource comes back as a tool error. A tool error is not an ALLOW, so Claude holds the action. Give the agent permission for the actions it may take on each resource. 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. See Agents.
This rule sends any export that leaves the company to a person. The format is on Policy files.
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 - ADMINApply it with npx -p @vulnify/sdk vulnify policies apply (an org-wide LIVE key), or create the same rule in the app under Policies. See CLI.
3. Approve a request from Claude
Section titled “3. Approve a request from Claude”Ask Claude for something the rule covers, for example: Export the 800 rows of this quarter’s order summary and email them to partner@example.com. Here Customer Database is registered with the classification Internal.
-
Before it sends anything, Claude calls
decide_action:{"agent": "claude","action": "EXPORT_DATA","resource": "Customer Database","destination": "EXTERNAL_EMAIL","recordsAffected": 800} -
Vulnify returns
finalDecisionREVIEW, with a risk score, the reasons, the matched policy, and an event id. Measured on a test workspace:REVIEW,HIGH, score65, reasons Export operation, External destination, Large data volume (800 records), Policy matched: review-external-exports. Claude holds the export and tells you it is waiting for approval in Vulnify. -
An approver resolves the review in the app, from the Slack app with Approve and Deny, or from the review email when review emails are on. 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 forHIGHorCRITICALapprovals when MFA is on for the approver’s account. -
Claude calls
get_decisionwith the event id, andwaitMsup to30000to wait in place. After approval,finalDecisionisALLOW,review.statusisAPPROVED, and Claude sends the export. After a denial or an expired review, it isBLOCKand Claude does not. If the wait ends first,waitTimedOutistrueandfinalDecisionis stillREVIEW. Ask Claude to check again later.
The stored decision stays REVIEW. Only finalDecision changes. The event and the review outcome are in the audit log.
Try the same calls from code
Section titled “Try the same calls from code”To see the flow without a chat, call the hosted server with an API key. An API-key session lists all seven tools. Use a TEST key: its events stay in the sandbox.
npm install @modelcontextprotocol/sdkimport { randomUUID } from 'node:crypto';import { Client } from '@modelcontextprotocol/sdk/client/index.js';import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
type Decision = { id: string; finalDecision: 'ALLOW' | 'REVIEW' | 'BLOCK'; reasons?: string[]; waitTimedOut?: boolean };
const apiKey = process.env.VULNIFY_API_KEY;if (!apiKey) throw new Error('Set VULNIFY_API_KEY');
const client = new Client({ name: 'vulnify-guide', version: '1.0.0' });await client.connect( new StreamableHTTPClientTransport(new URL('https://mcp.vulnify.io/mcp'), { requestInit: { headers: { Authorization: `Bearer ${apiKey}` } }, }),);
async function call(name: string, args: Record<string, unknown>): Promise<Decision> { const result = await client.callTool({ name, arguments: args }); if (result.isError) throw new Error(`${name} failed: ${JSON.stringify(result.content)}`); // an error is not an ALLOW return result.structuredContent as Decision;}
// 1. Before the export, record the decision.let decision = await call('decide_action', { agent: 'claude', action: 'EXPORT_DATA', resource: 'Customer Database', destination: 'EXTERNAL_EMAIL', recordsAffected: 800, idempotencyKey: randomUUID(),});console.log('decide_action:', decision.finalDecision, decision.reasons ?? [], decision.id);
// 2. REVIEW: hold the export and wait for a person, 30 seconds per call, up to 5 minutes.for (let i = 0; decision.finalDecision === 'REVIEW' && i < 10; i++) { decision = await call('get_decision', { id: decision.id, waitMs: 30_000 }); console.log('get_decision:', decision.finalDecision, decision.waitTimedOut ? '(still waiting)' : '');}
// 3. Only ALLOW runs the export.console.log(decision.finalDecision === 'ALLOW' ? 'export runs' : `export does not run (${decision.finalDecision})`);await client.close();Run it with VULNIFY_API_KEY set, then approve the review in the app while it waits. idempotencyKey makes a retry return the same event instead of recording a second one.
Next steps
Section titled “Next steps”- Enforce the decision on every tool call in Claude Code: Enforce in Claude Code.
- Put Vulnify in an agent’s MCP tool path: MCP gateway (beta).
- All seven tools and their scopes: Vulnify MCP server.
- Create the policy in the Vulnify app.

