Defining a net
bun add @petriflow/gate A skill net defines places, transitions, and which tools each transition gates. defineSkillNet is a type-safe identity function — it validates the place/marking types at compile time.
import { defineSkillNet } from "@petriflow/gate";
const toolApproval = defineSkillNet({
name: "tool-approval",
places: ["idle", "ready"],
terminalPlaces: [],
freeTools: ["ls", "read", "grep", "find"],
initialMarking: { idle: 1, ready: 0 },
transitions: [
{ name: "start", type: "auto", inputs: ["idle"], outputs: ["ready"] },
{ name: "execShell", type: "manual", inputs: ["ready"], outputs: ["ready"], tools: ["bash"] },
{ name: "execWrite", type: "manual", inputs: ["ready"], outputs: ["ready"], tools: ["write", "edit"] },
],
}); Read-only tools go in freeTools. Transitions with tools gate those tool names. Transitions without tools are structural (like the start transition that auto-fires on creation).
Transition types
auto
Fires immediately when the tool is called and the transition is enabled (its input places have tokens). No human involvement.
manual
Requires human approval via ctx.confirm() before firing. The runtime calls the confirm callback and waits. If approved, the transition fires. If rejected, the tool is blocked. If no UI is available (ctx.hasUI === false), manual transitions are always blocked.
Runtime calls ctx.confirm() — waits for human
OK transition fires, tool executes
BLOCKED tool never executes
Free tools
Tools listed in freeTools are always allowed regardless of net state. They bypass all transition checks. Use this for read-only, side-effect-free operations where gating adds no safety value.
A tool can be free in one net and gated in another. When nets are composed, the tool is only free if no net blocks it. A free verdict from one net doesn't override a blocked verdict from another.
Tool mapping
Split one physical tool into multiple virtual tool names based on input content. The mapper runs before any gate check, so transitions reference the virtual name.
const net = defineSkillNet({
name: "git-flow",
places: ["working", "committed"],
terminalPlaces: [],
freeTools: ["bash"],
initialMarking: { working: 1, committed: 0 },
toolMapper: (event) => {
if (event.toolName !== "bash") return event.toolName;
const cmd = event.input.command as string;
if (/\bgit\s+commit\b/.test(cmd)) return "git-commit";
if (/\bgit\s+push\b/.test(cmd)) return "git-push";
return "bash";
},
transitions: [
{ name: "commit", type: "auto", inputs: ["working"], outputs: ["committed"], tools: ["git-commit"] },
{ name: "push", type: "auto", inputs: ["committed"], outputs: ["working"], tools: ["git-push"] },
],
}); Mapper returns "bash" → FREE
Mapper returns "git-commit" → OK working→committed
Mapper returns "git-push" → BLOCKED needs committed token
Mapper returns "git-push" → OK committed→working
Tools that don't match any mapper pattern return the original tool name. If that name doesn't appear in any transition, the net abstains — no opinion, no block.
The rules DSL generates tool mappers automatically from dot notation (discord.sendMessage) and map statements.
Deferred transitions
Set deferred: true to allow the tool call immediately but only advance the net when the tool succeeds:
const backupNet = defineSkillNet({
name: "backup-before-delete",
places: ["idle", "ready", "backedUp"],
terminalPlaces: [],
freeTools: [],
initialMarking: { idle: 1, ready: 0, backedUp: 0 },
transitions: [
{ name: "start", type: "auto", inputs: ["idle"], outputs: ["ready"] },
{
name: "backup",
type: "auto",
inputs: ["ready"],
outputs: ["backedUp"],
tools: ["backup"],
deferred: true, // fires on successful tool_result, not tool_call
},
{ name: "delete", type: "auto", inputs: ["backedUp"], outputs: ["ready"], tools: ["delete"] },
],
}); ALLOWED tool executes. Transition recorded as pending. Marking unchanged.
Deferred transition fires. ready→backedUp. Delete now available.
ALLOWED backedUp→ready. Cycle resets.
If the tool fails (isError: true), the pending entry is cleared and the marking stays unchanged. The tool was allowed but the net didn't advance — the prerequisite wasn't met.
This matters for safety. Without deferral, the token moves the moment the tool is called, before knowing whether it succeeded. A failed backup would still unlock delete. Deferral closes that gap.
Semantic validation
Net structure alone can't express input-level constraints like "only write to /workspace/". Use validateToolCall for domain-specific checks that run after the structural check passes:
const pathGuard = defineSkillNet({
name: "write-path-guard",
places: ["idle", "ready"],
terminalPlaces: [],
freeTools: [],
initialMarking: { idle: 1, ready: 0 },
transitions: [
{ name: "start", type: "auto", inputs: ["idle"], outputs: ["ready"] },
{ name: "write", type: "auto", inputs: ["ready"], outputs: ["ready"], tools: ["write-file"] },
],
validateToolCall: (event, resolvedTool, transition, state) => {
const path = event.input.path as string;
if (!path.startsWith("/workspace/")) {
return { block: true, reason: `writes restricted to /workspace/, got ${path}` };
}
},
}); The validator receives the full event, resolved tool name, matched transition, and current state (including meta). Return { block: true, reason } to reject, or void to allow.
onDeferredResult
Record metadata when deferred transitions resolve. Combined with validateToolCall, this enables path-aware safety checks:
const smartBackup = defineSkillNet({
name: "smart-backup",
places: ["idle", "ready", "backedUp"],
// ...transitions omitted for brevity
onDeferredResult: (event, resolvedTool, transition, state) => {
// Record which paths have been backed up
const paths = (state.meta.backedUpPaths as string[]) ?? [];
paths.push(event.input.path as string);
state.meta.backedUpPaths = paths;
},
validateToolCall: (event, resolvedTool, transition, state) => {
if (resolvedTool === "delete") {
const paths = (state.meta.backedUpPaths as string[]) ?? [];
const target = event.input.path as string;
if (!paths.some((p) => target.startsWith(p))) {
return { block: true, reason: `${target} not backed up` };
}
}
},
}); The state.meta object persists across tool calls within a session. Use it to track information the net structure can't represent — backed-up paths, approved targets, running totals.
Single-net usage
For simple cases or when building a custom adapter, use the low-level single-net API directly:
import { handleToolCall, handleToolResult, createGateState, autoAdvance } from "@petriflow/gate";
const state = createGateState(autoAdvance(net, { ...net.initialMarking }));
const decision = await handleToolCall(
{ toolCallId: "1", toolName: "bash", input: { command: "rm -rf build/" } },
{ hasUI: true, confirm: async (title, msg) => window.confirm(msg) },
net,
state,
);
if (decision?.block) {
console.log(`Blocked: ${decision.reason}`);
} autoAdvance fires structural transitions (those without tools) immediately after creation and after each tool call. createGateState initializes the state with the marking, an empty meta object, and an empty pending map.
Gate manager
For multi-net composition, use createGateManager. Two modes:
Static
Pass an array of nets. All are always active. No runtime changes.
import { createGateManager } from "@petriflow/gate";
const manager = createGateManager([netA, netB]);
// Gate a tool call — returns { block: true, reason } or undefined
const decision = await manager.handleToolCall(
{ toolCallId: "1", toolName: "delete", input: { path: "/data" } },
{ hasUI: false, confirm: async () => false },
);
// Feed results back for deferred transitions
manager.handleToolResult({
toolCallId: "1",
toolName: "delete",
input: { path: "/data" },
isError: false,
}); Registry
Pass a config with all nets registered upfront. Activate a subset. Add or remove at runtime.
import { createGateManager } from "@petriflow/gate";
const manager = createGateManager({
registry: { safety: netA, deploy: netB, approval: netC },
active: ["safety"],
});
// Activate nets at runtime
manager.addNet("deploy");
// { ok: true, message: "Activated 'deploy'" }
// Deactivate — state is preserved for later reactivation
manager.removeNet("safety");
// { ok: true, message: "Deactivated 'safety' (state preserved)" }
// Inspect
manager.formatStatus();
// "safety (inactive): ready:1\ndeploy (active): idle:1\napproval (inactive): idle:1" Net state is preserved on deactivation. When reactivated, the net resumes from where it left off. Inactive nets don't participate in gating decisions.
Composition semantics
When multiple nets are composed, each independently classifies a tool call into one of four verdicts:
One blocked verdict from any net rejects the call. If no net blocks, gated nets fire their transitions. If all nets are free or abstain, the call passes through.
The gate manager runs a 4-phase pipeline on every tool call:
If any phase rejects, later phases don't run. Phase 3 includes meta rollback: if a validator rejects after earlier validators mutated state.meta, all mutations are reverted.
Shadow mode
Pass mode: "shadow" to observe gating decisions without blocking anything. The onDecision callback fires on every call.
const manager = createGateManager([netA, netB], {
mode: "shadow",
onDecision: (event, decision) => {
if (decision?.block) {
console.warn(`[AUDIT] ${event.toolName} would be blocked: ${decision.reason}`);
}
},
});
// Tools always execute. onDecision fires on every call. In shadow mode, blocked decisions are converted to undefined (allow) after the callback fires. Use this for auditing before switching to "enforce".
API reference
SkillNet<Place>
type SkillNet<Place extends string> = {
name: string;
places: Place[];
transitions: GatedTransition<Place>[];
initialMarking: Marking<Place>;
terminalPlaces: Place[];
freeTools: string[];
toolMapper?: (event: ToolEvent) => string;
validateToolCall?(event, resolvedTool, transition, state): { block: true; reason: string } | void;
onDeferredResult?(event, resolvedTool, transition, state): void;
}; GatedTransition<Place>
type GatedTransition<Place extends string> = {
name: string;
type: "auto" | "manual";
inputs: Place[];
outputs: Place[];
tools?: string[];
deferred?: boolean;
}; - type:
"auto"fires immediately,"manual"requires human approval - tools: tool names this transition gates. Omit for structural transitions.
- deferred: if true, tool is allowed immediately but transition fires on successful result
GateManager
type GateManager = {
handleToolCall(event: GateToolCall, ctx: GateContext): Promise<GateDecision>;
handleToolResult(event: GateToolResult): void;
addNet(name: string): { ok: boolean; message: string };
removeNet(name: string): { ok: boolean; message: string };
getActiveNets(): Array<{ name: string; net: SkillNet<string>; state: GateState<string> }>;
formatStatus(): string;
formatSystemPrompt(): string;
isDynamic: boolean;
}; - handleToolCall: gate a tool call. Returns
{ block: true, reason }orundefined. - handleToolResult: feed results back for deferred transitions.
- addNet / removeNet: registry mode only. State preserved on deactivation.
- formatStatus: current marking of all nets, one line each.
- formatSystemPrompt: markdown for LLM context — active nets, available tools, current state.
Event types
type GateToolCall = {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
};
type GateToolResult = {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
isError: boolean;
};
type GateContext = {
hasUI: boolean;
confirm: (title: string, message: string) => Promise<boolean>;
};
type GateDecision = { block: true; reason: string } | undefined; Utility functions
defineSkillNet(config)— type-safe net constructor (identity function)createGateManager(input, opts?)— multi-net manager (array or registry config)handleToolCall(event, ctx, net, state)— single-net gatinghandleToolResult(event, net, state)— single-net deferred resolutionautoAdvance(net, marking)— fire structural auto transitionscreateGateState(marking)— initialize state with marking, empty meta and pendingclassifyNets(nets, states, event)— phase 1 structural check (non-mutating)formatMarking(marking)— format as"ready:1, working:0"getEnabledToolTransitions(net, marking)— list currently fireable tool transitionsresolveTool(net, event)— apply tool mapper