What you'll build
A DevOps assistant that monitors dependency updates, researches releases, notifies the team on Slack, runs the deployment pipeline, emails the manager, and cleans up files. Five independent safety domains, all enforced simultaneously:
| Domain | Tools | Rules |
|---|---|---|
| Research | webSearch | None (free) |
| Slack | slack (readMessages, sendMessage) | Read before send, limit 10 |
readInbox, sendEmail | Human approval, limit 3 | |
| Deployment | lint, test, deploy, checkStatus | lint → test → deploy + approval, limit 2 |
| Files | listFiles, readFile, backup, delete, rm | Backup before delete, rm blocked |
Prerequisites
bun add @petriflow/vercel-ai @petriflow/rules ai @ai-sdk/anthropic zod This tutorial builds on concepts from the previous three tutorials: File Safety (sequencing, blocking), Deployment (chained dependencies, approval, limits), and Discord Bot (dot notation, action dispatch).
Write the rules
Create a file called assistant.rules:
How composition works
Each rule compiles to its own small, independently verified Petri net. At runtime, every net is checked on every tool call. A tool is allowed only if all nets agree.
The critical insight: nets that don't mention a tool abstain. The file safety net (require backup before delete) has no opinion on slack.sendMessage. The Slack rate limit net has no opinion on deploy. Each domain enforces its own rules without interference.
Notice that webSearch appears nowhere in the rules. No net mentions it, so every net abstains, and it's always allowed. Not every tool needs a rule. The safety is on the output channels (Slack, email, deploy), not the research tool.
Define the tools
13 tools across 5 domains. Each tool is a standard Vercel AI SDK tool() definition, with execute functions delegating to service objects (web, slack, email, pipeline, files) that contain the real implementations. PetriFlow handles the gating automatically.
import { tool } from "ai";
import { z } from "zod";
import { web, slack, email, pipeline, files } from "./tools";
const myTools = {
// --- Research (free — no rules mention it) ---
webSearch: tool({
description: "Search the web for information",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => web.search(query),
}),
// --- Slack (dot notation: slack.readMessages, slack.sendMessage) ---
slack: tool({
description: "Interact with Slack: read messages or send a message",
inputSchema: z.object({
action: z.enum(["readMessages", "sendMessage"]),
channel: z.string().describe("Channel name"),
content: z.string().optional().describe("Message content"),
}),
execute: async (input) => {
switch (input.action) {
case "readMessages":
return slack.readMessages(input.channel);
case "sendMessage":
return slack.sendMessage(input.channel, input.content!);
}
},
}),
// --- Email ---
readInbox: tool({
description: "Read email inbox for recent messages",
inputSchema: z.object({}),
execute: async () => email.readInbox(),
}),
sendEmail: tool({
description: "Send an email (requires human approval)",
inputSchema: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
execute: async ({ to, subject, body }) => email.send(to, subject, body),
}),
// --- Deployment ---
lint: tool({
description: "Run the linter on the codebase",
inputSchema: z.object({}),
execute: async () => pipeline.lint(),
}),
test: tool({
description: "Run the test suite",
inputSchema: z.object({}),
execute: async () => pipeline.test(),
}),
deploy: tool({
description: "Deploy to an environment",
inputSchema: z.object({
environment: z.enum(["production", "staging"]),
}),
execute: async ({ environment }) => pipeline.deploy(environment),
}),
checkStatus: tool({
description: "Check the current deployment status",
inputSchema: z.object({
environment: z.enum(["production", "staging"]),
}),
execute: async ({ environment }) => pipeline.checkStatus(environment),
}),
// --- Files ---
listFiles: tool({
description: "List files in a directory",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => files.list(path),
}),
readFile: tool({
description: "Read a file's contents",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => files.read(path),
}),
backup: tool({
description: "Create a backup of a file",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => files.backup(path),
}),
delete: tool({
description: "Delete a file (requires backup first)",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => files.remove(path),
}),
rm: tool({
description: "Remove a file with force",
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => files.forceRemove(path),
}),
}; The slack tool uses dot notation (same pattern as the Discord bot tutorial). The compiler auto-generates a toolMapper from the action field.
Wire it up
Same three-step pattern as every tutorial: load rules, create gate, wrap tools.
import { loadRules } from "@petriflow/rules";
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { generateText, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { createInterface } from "readline";
import { z } from "zod";
import { web, slack, email, pipeline, files } from "./tools";
// Interactive terminal prompt — blocks until the user types y/n
async function askApproval(title: string, message: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`\n--- APPROVAL REQUIRED ---\n${title}\n${message}\nApprove? (y/n) `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
// 1. Load and verify rules
const { nets, verification } = await loadRules("./assistant.rules");
console.log(verification);
// [
// { name: "require-slack.readMessages-before-slack.sendMessage", ... },
// { name: "limit-slack.sendMessage-10", ... },
// { name: "require-human-approval-before-sendEmail", ... },
// { name: "limit-sendEmail-3", ... },
// { name: "require-lint-before-test", ... },
// { name: "require-test-before-deploy", ... },
// { name: "require-human-approval-before-deploy", ... },
// { name: "limit-deploy-2", ... },
// { name: "require-backup-before-delete", ... },
// { name: "block-rm", ... },
// ]
// 2. Create the gate — deploy and sendEmail pause for real human approval
const gate = createPetriflowGate(nets, {
confirm: askApproval,
});
// 3. Define and wrap all 13 tools (see "Define the tools" above)
const tools = gate.wrapTools({
webSearch: tool({ /* ... */ }),
slack: tool({ /* ... */ }),
readInbox: tool({ /* ... */ }),
sendEmail: tool({ /* ... */ }),
lint: tool({ /* ... */ }),
test: tool({ /* ... */ }),
deploy: tool({ /* ... */ }),
checkStatus: tool({ /* ... */ }),
listFiles: tool({ /* ... */ }),
readFile: tool({ /* ... */ }),
backup: tool({ /* ... */ }),
delete: tool({ /* ... */ }),
rm: tool({ /* ... */ }),
});
// 4. Run the agent
const { text } = await generateText({
model: anthropic("claude-sonnet-4-5-20250929"),
tools,
system: `You are a DevOps assistant.\n\n${gate.systemPrompt()}`,
stopWhen: stepCountIs(20),
prompt:
"Check my inbox for dependency update notifications, research the latest " +
"Node.js 22 release, let the team know on Slack, run the deployment pipeline " +
"for production, email my manager a status update, and clean up temp files.",
}); 10 rules compile to 10 nets. Each is verified in isolation. The compiler reports reachable states for every net. All 13 tools go through the gate on every call.
See it in action
The agent tackles a complex multi-domain task. Watch how rules from different domains enforce independently:
BLOCKED lint and test required first
OK free tool, no rules mention it
OK free tool, no rules mention it
OK earns sendMessage token
ALLOWED budget: 9/10 remaining
OK earns delete token
OK backup was done
OK pipeline prerequisites satisfied
DEPLOYED v2.1.0, budget: 1/2 remaining
BLOCKED permanently, agent must use backup + delete instead
Key observations:
- Each domain operates independently. The file backup doesn't need to happen before Slack messages. Deployment doesn't wait for email.
webSearchandreadInboxwork immediately. Free tools need no prerequisites.- The agent interleaves across domains freely. The only constraints are within each domain.
rmis permanently blocked regardless of what happens in other domains.
Concepts
| Cross-domain composition | 10 rules, 5 domains, zero interference. Each rule compiles to an independent net that only cares about the tools it mentions. |
| Independent verification | Each net is verified in isolation. The Slack rate limit net has 12 reachable states; the file safety net has 3. Neither affects the other's verification. |
| Abstaining nets | Nets ignore tools they don't mention. This is why composition works: adding a new rule for a new domain doesn't interfere with existing rules. |
| Free tools | Not every tool needs a rule. webSearch is ungated because the safety is on output channels, not research. |
| Production scale | Add more rules for new capabilities (e.g. a database domain) without affecting existing rules. Each new rule is a new net that composes automatically. |
Next steps
| Tutorial 5: Workflow Safety | Go beyond if/then rules. Resource tokens, mutual exclusion, and formally verified deployment pipelines. |
| Rules Engine reference | Full DSL syntax, composition model, and how rules compile to nets. |
| Gate reference | Skill nets, deferred transitions, multi-net composition internals. |
| Vercel AI SDK docs | Shadow mode, registry mode, error handling patterns. |