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.

// manual transition flow
Agent calls bash (gated by manual transition)
Runtime calls ctx.confirm() — waits for human
Human approves
OK transition fires, tool executes
Human rejects
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"] },
  ],
});
// tool mapping resolves bash into virtual tools
Agent calls bash({ command: "ls -la" })
Mapper returns "bash" → FREE
Agent calls bash({ command: "git commit -m fix" })
Mapper returns "git-commit" → OK working→committed
Agent calls bash({ command: "git push" })
Mapper returns "git-push" → BLOCKED needs committed token
After commit succeeds, agent calls bash({ command: "git push" })
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"] },
  ],
});
// deferred: net waits for tool result
Agent calls backup
ALLOWED tool executes. Transition recorded as pending. Marking unchanged.
Tool result: success
Deferred transition fires. ready→backedUp. Delete now available.
Agent calls delete
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:

free tool is in freeTools — always allowed abstain tool doesn't appear in any transition — no opinion gated an enabled transition covers this tool — allowed blocked net has jurisdiction but no enabled transition — rejected

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:

Phase 1: Structural check — classify all nets (non-mutating) Phase 2: Manual approvals — prompt for human confirmation if needed Phase 3: Semantic validation — run validateToolCall on gated nets Phase 4: Commit — fire transitions, record deferred

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 } or undefined.
  • 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 gating
  • handleToolResult(event, net, state) — single-net deferred resolution
  • autoAdvance(net, marking) — fire structural auto transitions
  • createGateState(marking) — initialize state with marking, empty meta and pending
  • classifyNets(nets, states, event) — phase 1 structural check (non-mutating)
  • formatMarking(marking) — format as "ready:1, working:0"
  • getEnabledToolTransitions(net, marking) — list currently fireable tool transitions
  • resolveTool(net, event) — apply tool mapper