What you'll build

An HTTP server where every request gets its own gated agent session. Rules compile once at startup. Each request gets fresh marking state — one user's progress never leaks into another's.

Prerequisites

bun add @petriflow/vercel-ai @petriflow/rules ai @ai-sdk/openai zod hono

You'll also need an API key for your model provider. This tutorial assumes you've read Tutorial 1: File Safety and Tutorial 2: Deployment Agent.

The problem

In a single-request script, you create one gate and one session. In a server, you need fresh state per request — but the compiled rules are the same every time. The mistake to avoid: calling wrapTools() once and sharing the session across requests.

// Old pattern — gate created once, shared across all requests
const { nets } = await loadRules("./safety.rules");
const gate = createPetriflowGate(nets, {
  isToolResultError: /* your classifier */ () => false,
});

// BUG: one session shared by all users!
const session = gate.wrapTools(myTools);

app.post("/chat", async (c) => {
  const result = streamText({
    tools: session.tools,        // shared state!
    system: session.systemPrompt(),
    // ...
  });
});

A session holds mutable state: markings, deferred transitions, rate-limit budgets. If you create one session at startup, all users share that state. Rate limits become global. Sequencing breaks across requests.

The solution

// New pattern — gate is stateless, each wrapTools() creates fresh state
const { nets } = await loadRules("./safety.rules");
const gate = createPetriflowGate(nets, {
  isToolResultError: /* your classifier */ () => false,
});

app.post("/chat", async (c) => {
  // Fresh session per request — independent markings, rate limits, state
  const session = gate.wrapTools(myTools);
  const result = streamText({
    tools: session.tools,
    system: session.systemPrompt(),
    // ...
  });
});

The gate itself is stateless. It holds the compiled nets and options, but no mutable state. Each wrapTools() call returns a fresh GateSession with independent markings, deferred tracking, and rate-limit budgets. The lifecycle is natural: one gate = one rule set, one session = one user's state.

This design makes the shared-state mistake structurally obvious. The gate is safe to share — it's the session you must create per request. And since sessions come from wrapTools(), the only way to get tools is to create a session.

Full server

Write the rules file:

# safety.rules require lint before deploy require human-approval before deploy limit deploy to 3 per session

Wire it up:

import { loadRules } from "@petriflow/rules";
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { streamText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { Hono } from "hono";

// 1. Compile once at startup
const { nets } = await loadRules("./safety.rules");

const gate = createPetriflowGate(nets, {
  isToolResultError: (_toolName, result) =>
    typeof result === "object" && result !== null &&
    "passed" in result && (result as any).passed === false,
  mode: "enforce",
  onDecision: (event, decision) => {
    if (decision?.block) {
      console.warn(`[BLOCKED] ${event.toolName}: ${decision.reason}`);
    }
  },
});

console.log(`Loaded ${nets.length} nets`);

// 2. Define tools (shared across all requests)
const myTools = {
  lint: tool({
    description: "Run the linter",
    inputSchema: z.object({}),
    execute: async () => ({ passed: true }),
  }),
  deploy: tool({
    description: "Deploy to production",
    inputSchema: z.object({ version: z.string() }),
    execute: async ({ version }) => ({ deployed: version }),
  }),
};

const app = new Hono();

// 3. Per request — fresh session
app.post("/chat", async (c) => {
  const session = gate.wrapTools(myTools);

  const result = streamText({
    model: openai("gpt-4o"),
    tools: session.tools,
    system: session.systemPrompt(),
    prompt: await c.req.text(),
  });

  return result.toTextStreamResponse();
});

export default app;

Each POST to /chat creates a fresh session via gate.wrapTools(). The gate compiles safety.rules once at startup. session.systemPrompt() tells the model about the constraints. session.tools contains the instrumented tools. Blocked calls throw ToolCallBlockedError, which the Vercel AI SDK feeds back to the model as a tool error.

// Two users, independent sessions
User A calls lint()
OK Session A advances: deploy now reachable
User B calls deploy()
BLOCKED Session B is fresh — lint hasn't run in this session
User A calls deploy()
ALLOWED Session A: lint done, deploy permitted
User B calls lint() then deploy()
ALLOWED Session B completes its own pipeline independently

Concepts

Stateless gateThe gate holds compiled nets and options but no mutable state. Safe to share across requests and create once at startup.
Per-request sessionsEach gate.wrapTools() call returns a GateSession with independent markings, deferred tracking, and rate-limit budgets.
Immutable netsCompiled rule nets are structurally immutable. The gate captures them once and shares them across all sessions.

Next steps

Vercel AI SDK API referenceFull API docs for createPetriflowGate, GateSession, and wrapTools.
Shadow modeAudit decisions before enforcing them in production.
Registry modeDynamic rule activation for multi-tenant servers.