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:
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:
BLOCKED readMessages required first
OK reads messages, sendMessage now unlocked
ALLOWED budget: 4/5 remaining
BLOCKED must readMessages again (rule cycles)
OK read-send cycle repeats. Budget: 3/5
OK free action, no rules mention addReaction
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.
addReactionandcreateThreadwork 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 notation | tool.action in rules auto-generates a toolMapper that resolves virtual tool names from the input's action field. |
| Action-dispatch tools | One physical tool can have actions gated independently. Only the actions mentioned in rules are controlled. |
| Cycling dependencies | Sequencing rules reset after each use, creating a read-send-read-send pattern that prevents spam. |
| Composing limits with sequencing | Rate limits and sequencing rules compose via AND logic. Both constraints must be satisfied. |
Next steps
| Tutorial 4: DevOps Assistant | One agent, 13 tools, 5 domains. See how all the concepts compose at production scale. |
| Rules Engine reference | Full DSL syntax including map for custom tool mapping. |
| Gate reference | Skill nets, deferred transitions, multi-net composition. |
| Vercel AI SDK docs | Shadow mode, registry mode, error handling. |