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:
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.
OK Session A advances: deploy now reachable
BLOCKED Session B is fresh — lint hasn't run in this session
ALLOWED Session A: lint done, deploy permitted
ALLOWED Session B completes its own pipeline independently
Concepts
| Stateless gate | The gate holds compiled nets and options but no mutable state. Safe to share across requests and create once at startup. |
| Per-request sessions | Each gate.wrapTools() call returns a GateSession with independent markings, deferred tracking, and rate-limit budgets. |
| Immutable nets | Compiled rule nets are structurally immutable. The gate captures them once and shares them across all sessions. |
Next steps
| Vercel AI SDK API reference | Full API docs for createPetriflowGate, GateSession, and wrapTools. |
| Shadow mode | Audit decisions before enforcing them in production. |
| Registry mode | Dynamic rule activation for multi-tenant servers. |