Quick start
Write your rules in a .rules file:
Load, compile, gate:
import { loadRules } from "@petriflow/rules";
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { generateText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const { nets } = await loadRules("./safety.rules");
const gate = createPetriflowGate(nets);
// Define your tools — PetriFlow wraps each one's execute method
const tools = gate.wrapTools({
backup: tool({
description: "Create a backup of a file",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => ({ backedUp: `${path}.bak` }),
}),
delete: tool({
description: "Delete a file (requires backup first)",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => ({ deleted: path }),
}),
});
const { text } = await generateText({
model: openai("gpt-4o"),
tools,
system: gate.systemPrompt(),
prompt: "Clean up old data files",
}); loadRules reads a .rules file and builds a verified Petri net for each rule. createPetriflowGate wraps the nets into a gate. wrapTools takes your Vercel AI SDK tool() definitions and instruments every tool's execute method. systemPrompt() tells the model which tools are gated and why, so it can plan around the constraints instead of hitting them blind.
How it works
wrapTools replaces each tool's execute with a wrapper that runs three phases:
// gate.wrapTools() instruments each tool's execute method:
// 1. Before execute → handleToolCall (gate check)
// Blocked? → throw ToolCallBlockedError, tool never runs
// 2. Execute → original tool.execute(input, options)
// 3. After execute → handleToolResult
// Success (isError: false) → deferred transitions advance
// Failure (isError: true) → marking unchanged BLOCKED handleToolCall rejects. ToolCallBlockedError thrown. Tool never executes.
OK handleToolCall allows. Tool executes. handleToolResult advances the net.
ALLOWED net satisfied. Tool executes normally.
Tools without an execute method (schema-only) pass through unchanged. The return type is preserved: wrapTools returns the same T you pass in, so type inference works everywhere.
For details on the rule DSL (sequences, blocks, limits, tool mapping), see the rules engine docs.
Using with rules
The full pipeline: write rules in the DSL, compile them, create a gate, wrap your tools. Three imports, four steps.
import { compile } from "@petriflow/rules";
import { createPetriflowGate, ToolCallBlockedError } from "@petriflow/vercel-ai";
import { generateText } from "ai";
const { nets } = compile(`
require lint before deploy
require human-approval before deploy
limit deploy to 2 per session
`);
const gate = createPetriflowGate(nets, {
confirm: async (title, message) => {
// your UI or CLI prompt here
return await askUser(message);
},
});
// myTools = your tool definitions (see Quick Start above)
const tools = gate.wrapTools(myTools);
const { text } = await generateText({
model: openai("gpt-4o"),
tools,
system: gate.systemPrompt(),
prompt: "Run lint, then deploy to production",
}); Each rule compiles to an independent Petri net. The gate checks all nets on every tool call. A tool fires only if every net allows it. Rules compose by intersection, the same way they do in @petriflow/rules directly. See how rules compose.
Manual confirmation
require human-approval before X rules compile to manual transitions. The runtime calls your confirm callback and waits for a response. If the human approves, the tool executes. If they reject, it's blocked. If no confirm callback is provided, manual transitions are always blocked.
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { compile } from "@petriflow/rules";
import * as readline from "readline";
const { nets } = compile("require human-approval before deploy");
// CLI: prompt on stdin
const gate = createPetriflowGate(nets, {
confirm: async (title, message) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`${message} (y/n) `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === "y");
});
});
},
});
// React: show a dialog
const gate2 = createPetriflowGate(nets, {
confirm: async (title, message) => {
return await showApprovalDialog({ title, message });
},
}); The callback receives two arguments: a title (e.g. "Approve: deploy") and a message (e.g. "Allow 'deploy' via transition 'approve' in net 'approve-before-deploy'?"). Return true to approve, false to reject.
Shadow mode
Set mode: "shadow" to observe gating decisions without blocking anything. Tools always execute. The onDecision callback fires on every call with what would have happened in enforce mode.
const gate = createPetriflowGate(nets, {
mode: "shadow",
onDecision: (event, decision) => {
if (decision?.block) {
console.warn(`[AUDIT] ${event.toolName} would be blocked: ${decision.reason}`);
}
},
});
// Tools execute normally. Nothing is blocked.
// onDecision fires on every call, so you see what *would* happen.
// myTools = your tool definitions (see Quick Start above)
const tools = gate.wrapTools(myTools); Use this for auditing an existing agent. Deploy shadow mode first, review the logs, then switch to "enforce" when you're confident the rules match your intent.
Registry mode
Pass a ComposeConfig instead of an array to enable dynamic net management. Register all nets upfront, activate a subset, and add or remove nets at runtime.
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { compile } from "@petriflow/rules";
const { nets: safetyNets } = compile("block rm");
const { nets: deployNets } = compile(`
require lint before deploy
require human-approval before deploy
`);
// Register all nets, activate only safety at startup
const gate = createPetriflowGate({
registry: {
safety: safetyNets[0],
deploy: deployNets[0],
approval: deployNets[1],
},
active: ["safety"],
});
// myTools = your tool definitions (see Quick Start above)
const tools = gate.wrapTools(myTools);
// Later: enable deploy policy at runtime
gate.addNet("deploy");
gate.addNet("approval");
// { ok: true, message: "Activated 'deploy'" }
// Disable when no longer needed
gate.removeNet("approval");
// { ok: true, message: "Deactivated 'approval' (state preserved)" } addNet and removeNet return { ok: boolean, message: string }. Net state is preserved on deactivation and restored on reactivation. Inactive nets don't participate in gating decisions.
Use this when your agent's permissions change mid-session: escalating privileges after authentication, enabling deploy rules only during a release window, or disabling rate limits for admin users.
Error handling
When a tool is blocked in enforce mode, wrapTools throws a ToolCallBlockedError. The original tool never executes.
import { ToolCallBlockedError } from "@petriflow/vercel-ai";
try {
const result = await generateText({
model: openai("gpt-4o"),
// myTools = your tool definitions (see Quick Start above)
tools: gate.wrapTools(myTools),
prompt: "Delete all logs",
});
} catch (e) {
if (e instanceof ToolCallBlockedError) {
console.log(e.toolName); // "delete"
console.log(e.toolCallId); // "call_abc123"
console.log(e.reason); // "[block-rm] Tool 'rm' not available ..."
console.log(e.message); // "Tool 'delete' blocked: [block-rm] ..."
}
} ToolCallBlockedError extends Error. Use instanceof to distinguish it from tool execution errors. The Vercel AI SDK catches tool errors and feeds them back to the model, so the agent sees the block reason and can adjust its plan.
API reference
createPetriflowGate(nets, opts?)
Creates a gate instance. Two overloads: pass an array of nets for static composition, or a ComposeConfig for registry mode with dynamic activation.
// Static: pass compiled nets directly
function createPetriflowGate(
nets: SkillNet<string>[],
opts?: GateOptions,
): PetriflowGate;
// Registry: dynamic activation/deactivation
function createPetriflowGate(
config: ComposeConfig,
opts?: GateOptions,
): PetriflowGate; GateOptions
type GateOptions = {
mode?: "enforce" | "shadow"; // default: "enforce"
confirm?: (title: string, message: string) => Promise<boolean>;
onDecision?: (event: GateToolCall, decision: GateDecision) => void;
}; - mode:
"enforce"throws on blocked calls."shadow"logs but never blocks. Default:"enforce". - confirm: callback for
require human-approvalrules. If omitted, manual transitions are blocked. - onDecision: fires after every gating decision. Receives the tool call event and the decision (
{ block: true, reason }orundefined).
PetriflowGate
type PetriflowGate = {
wrapTools: <T extends Record<string, any>>(tools: T) => T;
systemPrompt: () => string;
formatStatus: () => string;
addNet: (name: string) => { ok: boolean; message: string };
removeNet: (name: string) => { ok: boolean; message: string };
manager: GateManager;
}; - wrapTools(tools): instruments each tool's
executewith gate checks. Returns the same type. - systemPrompt(): returns a formatted prompt describing active nets, gated tools, and current state. Pass this to the model so it knows the constraints.
- formatStatus(): returns the current marking of all nets. Useful for debugging.
- addNet(name): registry mode only. Activates a registered net.
- removeNet(name): registry mode only. Deactivates a net. State is preserved for later reactivation.
ToolCallBlockedError
class ToolCallBlockedError extends Error {
readonly toolName: string;
readonly toolCallId: string;
readonly reason: string;
} - toolName: the tool that was blocked
- toolCallId: the specific invocation ID
- reason: human-readable explanation from the gate
- message: formatted as
"Tool '<name>' blocked: <reason>"
vercelAiToolApprovalNet
A bundled Petri net for a common pattern: read-only tools are free, write tools are gated.
import { vercelAiToolApprovalNet } from "@petriflow/vercel-ai";
// or: import { vercelAiToolApprovalNet } from "@petriflow/vercel-ai/nets/tool-approval";
const gate = createPetriflowGate([vercelAiToolApprovalNet]);
// Free tools: readData, fetchData (always allowed)
// Gated tools: writeData, sendEmail (require "ready" state) Available from the main export or from @petriflow/vercel-ai/nets/tool-approval.