What you'll build

A Discord bot with a single discord tool that dispatches actions via an action parameter: readMessages, sendMessage, addReaction, and createThread. Two rules control behavior: the agent must read messages before sending, and sending is limited to 5 messages per session.

Prerequisites

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

This tutorial assumes you've read Tutorial 1: File Safety and Tutorial 2: Deployment.

Write the rules

Create a file called messaging.rules:

# messaging.rules require discord.readMessages before discord.sendMessage limit discord.sendMessage to 5 per session

How dot notation works

The rules reference discord.readMessages and discord.sendMessage, but your actual tool is just discord. The compiler sees the dot and auto-generates a toolMapper that resolves the virtual tool name from the action field in the tool's input:

# These rules reference discord.readMessages and discord.sendMessage.
# The compiler sees the dot notation and auto-generates a toolMapper
# that resolves the virtual tool name from input.action.
#
# discord({ action: "readMessages", ... }) → virtual tool "discord.readMessages"
# discord({ action: "sendMessage", ... })  → virtual tool "discord.sendMessage"
# discord({ action: "addReaction", ... })  → virtual tool "discord.addReaction" (ungated)

require discord.readMessages before discord.sendMessage
limit discord.sendMessage to 5 per session

This means one physical tool (discord) can have different actions gated independently. readMessages and sendMessage are controlled by rules. addReaction and createThread are free. No rules mention them.

Cycling dependencies

The sequencing rule require discord.readMessages before discord.sendMessage cycles: after each send, the agent must read again before the next send. This naturally interleaves reading context with sending replies. The agent can't spam messages without checking what's new.

Define the tools

A single tool with an action enum. The tool definition is standard Vercel AI SDK, with the execute function delegating to a discord service object that wraps the Discord REST API. PetriFlow's dot notation handles the dispatch mapping automatically.

import { tool } from "ai";
import { z } from "zod";
import { discord } from "./tools";

const myTools = {
  discord: tool({
    description:
      "Interact with Discord: read messages, send messages, add reactions, or create threads",
    inputSchema: z.object({
      action: z.enum([
        "readMessages",
        "sendMessage",
        "addReaction",
        "createThread",
      ]),
      channel: z.string().describe("Channel name"),
      content: z.string().optional().describe("Message content (for sendMessage)"),
      messageId: z.string().optional().describe("Target message ID (for addReaction)"),
      emoji: z.string().optional().describe("Emoji to react with"),
      threadName: z.string().optional().describe("Thread name (for createThread)"),
    }),
    execute: async (input) => {
      switch (input.action) {
        case "readMessages":
          return discord.readMessages(input.channel);
        case "sendMessage":
          return discord.sendMessage(input.channel, input.content!);
        case "addReaction":
          return discord.addReaction(input.channel, input.messageId!, input.emoji!);
        case "createThread":
          return discord.createThread(input.channel, input.threadName!);
      }
    },
  }),
};

The action parameter is what PetriFlow uses for tool mapping. When the agent calls discord({ action: "sendMessage", ... }), PetriFlow resolves it to the virtual tool discord.sendMessage and checks the rules against that name.

Wire it up

Same pattern as the previous tutorials. No special configuration needed. The dot notation toolMapper is auto-generated from the rules.

import { loadRules } from "@petriflow/rules";
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { generateText, tool, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { discord } from "./tools";

// 1. Load and verify rules
const { nets, verification } = await loadRules("./messaging.rules");
console.log(verification);
// [
//   { name: "require-discord.readMessages-before-discord.sendMessage", reachableStates: 3 },
//   { name: "limit-discord.sendMessage-5", reachableStates: 7 },
// ]

// 2. Create the gate
const gate = createPetriflowGate(nets);

// 3. Define and wrap tools — a single "discord" tool with an action enum
const tools = gate.wrapTools({
  discord: tool({
    description:
      "Interact with Discord: read messages, send messages, add reactions, or create threads",
    inputSchema: z.object({
      action: z.enum(["readMessages", "sendMessage", "addReaction", "createThread"]),
      channel: z.string().describe("Channel name"),
      content: z.string().optional().describe("Message content"),
      messageId: z.string().optional().describe("Target message ID"),
      emoji: z.string().optional().describe("Emoji to react with"),
      threadName: z.string().optional().describe("Thread name"),
    }),
    execute: async (input) => {
      switch (input.action) {
        case "readMessages":
          return discord.readMessages(input.channel);
        case "sendMessage":
          return discord.sendMessage(input.channel, input.content!);
        case "addReaction":
          return discord.addReaction(input.channel, input.messageId!, input.emoji!);
        case "createThread":
          return discord.createThread(input.channel, input.threadName!);
      }
    },
  }),
});

// 4. Run the agent
const { text } = await generateText({
  model: openai("gpt-4o"),
  tools,
  system: gate.systemPrompt(),
  stopWhen: stepCountIs(15),
  prompt:
    "In #dev-general: read messages, reply about the build failure, and send a few follow-ups.",
});

See it in action

The agent tries to interact with a Discord channel:

// agent manages #dev-general
Agent calls discord({ action: "sendMessage", channel: "#dev-general", content: "Hello!" })
BLOCKED readMessages required first
Agent calls discord({ action: "readMessages", channel: "#dev-general" })
OK reads messages, sendMessage now unlocked
Agent calls discord({ action: "sendMessage", channel: "#dev-general", content: "Looking at the build failure..." })
ALLOWED budget: 4/5 remaining
Agent tries to send another message immediately
BLOCKED must readMessages again (rule cycles)
Agent calls discord({ action: "readMessages" }) then discord({ action: "sendMessage" })
OK read-send cycle repeats. Budget: 3/5
Agent calls discord({ action: "addReaction", emoji: "👍" })
OK free action, no rules mention addReaction
After 5 sends, agent tries to send again
BLOCKED rate limit reached, budget exhausted

Key observations:

  • The sequencing rule cycles. After each send, readMessages is required again. The agent can't batch-fire messages.
  • The rate limit and sequencing compose via AND logic. A send needs both a prior read and remaining budget.
  • addReaction and createThread work at any point. They're free actions.
  • After 5 sends, the budget is permanently exhausted. The agent can still read, react, and create threads.

Concepts

Dot notationtool.action in rules auto-generates a toolMapper that resolves virtual tool names from the input's action field.
Action-dispatch toolsOne physical tool can have actions gated independently. Only the actions mentioned in rules are controlled.
Cycling dependenciesSequencing rules reset after each use, creating a read-send-read-send pattern that prevents spam.
Composing limits with sequencingRate limits and sequencing rules compose via AND logic. Both constraints must be satisfied.

Next steps

Tutorial 4: DevOps AssistantOne agent, 13 tools, 5 domains. See how all the concepts compose at production scale.
Rules Engine referenceFull DSL syntax including map for custom tool mapping.
Gate referenceSkill nets, deferred transitions, multi-net composition.
Vercel AI SDK docsShadow mode, registry mode, error handling.