Quick start

bun add @petriflow/claude-code

Add hooks to your project's .claude/settings.json:

{
  "hooks": {
    "SessionStart": [{
      "hooks": [{ "type": "command", "command": "bun run node_modules/@petriflow/claude-code/src/hook.ts" }]
    }],
    "PreToolUse": [{
      "hooks": [{ "type": "command", "command": "bun run node_modules/@petriflow/claude-code/src/hook.ts" }]
    }],
    "PostToolUse": [{
      "hooks": [{ "type": "command", "command": "bun run node_modules/@petriflow/claude-code/src/hook.ts" }]
    }],
    "PostToolUseFailure": [{
      "hooks": [{ "type": "command", "command": "bun run node_modules/@petriflow/claude-code/src/hook.ts" }]
    }]
  }
}

That's it. The default net blocks Bash, gates file mutations (Write, Edit), and lets read-only tools through freely. State persists across hook invocations automatically.

How it works

Claude Code hooks spawn a new process per event. There's no persistent connection. The adapter handles this by persisting gate state to a JSON file between invocations:

// hook lifecycle for each Claude Code session
SessionStart
Clear stale state delete /tmp/petriflow-claude-code-{session_id}.json
PreToolUse
Restore state → gate the tool call → persist state → output allow/deny
PostToolUse
Restore state → resolve deferred transitions on success → persist state
PostToolUseFailure
Restore state → clear pending (tool failed, marking unchanged) → persist state

On PreToolUse, the hook reads stdin (the tool call event), checks it against all active nets, and writes a JSON response to stdout. If the tool is blocked, the response includes permissionDecision: "deny" and Claude Code stops the tool from executing.

On PostToolUse, deferred transitions fire. If a tool was allowed but its execution is needed to advance the net (like a backup before delete), the marking only moves forward after confirmed success.

Default net

With no config file, the adapter uses safeCodingNet:

free tools: Read, Glob, Grep, WebSearch gated tools: Write, Edit, WebFetch, Task blocked tools: Bash places: idle(1) ready(0) locked(0) transitions: start(idleready) writeFile(readyready) tools: Write editFile(readyready) tools: Edit webFetch(readyready) tools: WebFetch spawnTask(readyready) tools: Task bashBlocked(lockedlocked) tools: Bash — never fires

Free tools pass through with no gate check. Gated tools consume and restore the ready token. Bash requires a token in locked, which starts at 0 and never receives one.

// default net in action
Claude calls Read (file.ts)
OK free tool, always allowed
Claude calls Edit (file.ts)
OK gated, ready token present
Claude calls Bash (npm install)
BLOCKED locked place has no token, structurally impossible
Claude calls Grep ("TODO")
OK free tool

Custom config

Create .claude/petriflow.config.ts in your project root to override the defaults:

// .claude/petriflow.config.ts
import { safeCodingNet } from "@petriflow/claude-code";

export default { nets: [safeCodingNet], mode: "enforce" as const };

The hook script checks for this file on every invocation. Export a default object with nets (array of skill nets) and mode ("enforce" or "shadow").

Shadow mode

Set mode: "shadow" to observe gating decisions without blocking anything. Tools always execute. Decisions are logged to stderr so you can review what would have been blocked.

// .claude/petriflow.config.ts
import { safeCodingNet } from "@petriflow/claude-code";

export default { nets: [safeCodingNet], mode: "shadow" as const };
// Tools always execute. Decisions logged to stderr.

Use this to evaluate rules against a real session before switching to "enforce". The stderr output shows every tool call, its decision, and the current net marking.

Custom nets

Replace the default net entirely by exporting your own. This example allows Bash but requires manual approval for every invocation:

// .claude/petriflow.config.ts
import { defineSkillNet, safeCodingNet } from "@petriflow/claude-code";

// Allow Bash but require human approval first
const approvedBash = defineSkillNet({
  name: "approved-bash",
  places: ["idle", "ready"],
  terminalPlaces: [],
  freeTools: [],
  initialMarking: { idle: 1, ready: 0 },
  transitions: [
    { name: "start", type: "auto", inputs: ["idle"], outputs: ["ready"] },
    { name: "runBash", type: "manual", inputs: ["ready"], outputs: ["ready"], tools: ["Bash"] },
  ],
});

export default { nets: [approvedBash], mode: "enforce" as const };

The type: "manual" transition means the tool can only fire with external confirmation. In Claude Code hooks, manual transitions are always denied (the hook has no UI for human approval), so this effectively blocks Bash unless you pair it with a custom approval flow.

For tools not mentioned in any net, the gate abstains and the tool passes through freely.

Composing with rules

Combine the rules DSL with the default net. DSL rules and custom nets compose independently via AND logic.

// .claude/petriflow.config.ts
import { defineSkillNet, safeCodingNet } from "@petriflow/claude-code";
import { compile } from "@petriflow/rules";

// Use the DSL for simple rules
const { nets: ruleNets } = compile(`
  require Read before Edit
  limit Write to 5 per session
`);

export default {
  nets: [...ruleNets, safeCodingNet],
  mode: "enforce" as const,
};

Each rule compiles to its own net. The gate manager checks all nets on every tool call. A tool fires only if every net allows it. safeCodingNet blocks Bash, and the DSL rules enforce ordering and limits on top.

API reference

safeCodingNet

The default safety net for Claude Code. Free: Read, Glob, Grep, WebSearch. Gated: Write, Edit, WebFetch, Task. Blocked: Bash.

configure(projectDir)

Generates the hooks configuration to merge into .claude/settings.json. The command path is resolved relative to the project's node_modules.

function configure(projectDir: string): HooksConfig

defineSkillNet(config)

Re-exported from @petriflow/gate. Build custom nets for things the default doesn't cover. See the rules API reference for the full SkillNet type.

createGateManager(nets, opts)

Re-exported from @petriflow/gate. Creates a runtime gate manager. Modes: "enforce" blocks disallowed calls, "shadow" logs but never blocks.

Config file format

Place at .claude/petriflow.config.ts. The hook loads this on every invocation.

type PetriflowConfig = { nets: SkillNet<string>[]; mode: "enforce" | "shadow"; }

State persistence

Gate state is persisted to /tmp/petriflow-claude-code-{session_id}.json between hook invocations. Cleared on SessionStart. Contains net markings, metadata, and pending deferred transitions.