Quick start

Write your rules in a .rules file:

# safety.rules require backup before delete block rm

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
// require backup before delete
Agent calls delete
BLOCKED handleToolCall rejects. ToolCallBlockedError thrown. Tool never executes.
Agent calls backup
OK handleToolCall allows. Tool executes. handleToolResult advances the net.
Agent calls delete
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-approval rules. If omitted, manual transitions are blocked.
  • onDecision: fires after every gating decision. Receives the tool call event and the decision ({ block: true, reason } or undefined).

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 execute with 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.