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:

DomainToolsRules
ResearchwebSearchNone (free)
Slackslack (readMessages, sendMessage)Read before send, limit 10
EmailreadInbox, sendEmailHuman approval, limit 3
Deploymentlint, test, deploy, checkStatuslint → test → deploy + approval, limit 2
FileslistFiles, readFile, backup, delete, rmBackup 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:

# assistant.rules # Slack — read channel context before sending, rate limited require slack.readMessages before slack.sendMessage limit slack.sendMessage to 10 per session # Email — human approves every send, rate limited require human-approval before sendEmail limit sendEmail to 3 per session # Deployment pipeline require lint before test require test before deploy require human-approval before deploy limit deploy to 2 per session # File safety require backup before delete block rm

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:

// DevOps assistant across 5 domains
Agent calls sendEmail({ to: "[email protected]", ... })
BLOCKED human approval required
Agent calls deploy({ environment: "production" })
BLOCKED lint and test required first
Agent calls webSearch({ query: "Node.js 22 release" })
OK free tool, no rules mention it
Agent calls readInbox({})
OK free tool, no rules mention it
Agent calls slack({ action: "readMessages", channel: "#devops" })
OK earns sendMessage token
Agent calls slack({ action: "sendMessage", channel: "#devops", content: "Node 22 LTS..." })
ALLOWED budget: 9/10 remaining
Agent calls backup({ path: "/tmp/project/temp.log" })
OK earns delete token
Agent calls delete({ path: "/tmp/project/temp.log" })
OK backup was done
Agent calls lint({}) then test({})
OK pipeline prerequisites satisfied
Human approves deploy → deploy({ environment: "production" })
DEPLOYED v2.1.0, budget: 1/2 remaining
Human approves sendEmail → sendEmail({ to: "[email protected]", ... })
SENT budget: 2/3 remaining
Agent calls rm({ path: "/tmp/project/cache.json" })
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.
  • webSearch and readInbox work immediately. Free tools need no prerequisites.
  • The agent interleaves across domains freely. The only constraints are within each domain.
  • rm is permanently blocked regardless of what happens in other domains.

Concepts

Cross-domain composition10 rules, 5 domains, zero interference. Each rule compiles to an independent net that only cares about the tools it mentions.
Independent verificationEach 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 netsNets 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 toolsNot every tool needs a rule. webSearch is ungated because the safety is on output channels, not research.
Production scaleAdd 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 SafetyGo beyond if/then rules. Resource tokens, mutual exclusion, and formally verified deployment pipelines.
Rules Engine referenceFull DSL syntax, composition model, and how rules compile to nets.
Gate referenceSkill nets, deferred transitions, multi-net composition internals.
Vercel AI SDK docsShadow mode, registry mode, error handling patterns.