Skip to main content
Custom hooks let you write policies for any agent behavior: enforce project conventions, prevent drift, gate destructive operations, detect stuck agents, or integrate with Slack, approval workflows, and more. They use the same hook event system and allow, deny, instruct decisions as built-in policies.

Quick example

// my-policies.js
import { customPolicies, allow, deny, instruct } from "failproofai";

customPolicies.add({
  name: "no-production-writes",
  description: "Block writes to paths containing 'production'",
  match: { events: ["PreToolUse"] },
  fn: async (ctx) => {
    if (ctx.toolName !== "Write" && ctx.toolName !== "Edit") return allow();
    const path = ctx.toolInput?.file_path ?? "";
    if (path.includes("production")) {
      return deny("Writes to production paths are blocked");
    }
    return allow();
  },
});
Install it:
failproofai policies --install --custom ./my-policies.js

Installing and updating

# Install with a custom hooks file
failproofai policies --install --custom ./my-policies.js

# Replace the hooks file path
failproofai policies --install --custom ./new-policies.js

# Remove the custom hooks path from config
failproofai policies --uninstall --custom
The resolved absolute path is stored in policies-config.json as customPoliciesPath. The file is loaded fresh on every hook event - there is no caching between events.

API

Import

import { customPolicies, allow, deny, instruct } from "failproofai";

customPolicies.add(hook)

Registers a hook. Call this as many times as needed for multiple hooks in the same file.
customPolicies.add({
  name: string;                         // required - unique identifier
  description?: string;                 // shown in `failproofai policies` output
  match?: { events?: HookEventType[] }; // filter by event type; omit to match all
  fn: (ctx: PolicyContext) => PolicyResult | Promise<PolicyResult>;
});

Decision helpers

FunctionEffectUse when
allow()Permit the tool callThe action is safe to proceed
deny(message)Block the tool callThe agent should not take this action
instruct(message)Add context to Claude’s promptGive the agent extra context to stay on track
deny(message) - the message appears to Claude prefixed with "Blocked by failproofai:". instruct(message) - the message is appended to Claude’s context for the current tool call. Multiple instruct returns from different hooks accumulate; a single deny short-circuits all further evaluation.

PolicyContext fields

FieldTypeDescription
eventTypestring"PreToolUse", "PostToolUse", "Notification", "Stop"
toolNamestring | undefinedThe tool being called (e.g. "Bash", "Write", "Read")
toolInputRecord<string, unknown> | undefinedThe tool’s input parameters
payloadRecord<string, unknown>Full raw event payload from Claude Code
sessionSessionMetadata | undefinedSession context (see below)

SessionMetadata fields

FieldTypeDescription
sessionIdstringClaude Code session identifier
cwdstringWorking directory of the Claude Code session
transcriptPathstringPath to the session’s JSONL transcript file

Event types

EventWhen it firestoolInput contents
PreToolUseBefore Claude runs a toolThe tool’s input (e.g. { command: "..." } for Bash)
PostToolUseAfter a tool completesThe tool’s input + tool_result (the output)
NotificationWhen Claude sends a notification{ message: "...", notification_type: "idle" | "permission_prompt" | ... } - hooks must always return allow(), they cannot block notifications
StopWhen the Claude session endsEmpty

Evaluation order

Hooks are evaluated in this order:
  1. Built-in policies (in definition order)
  2. Custom hooks (in .add() order)
The first deny short-circuits all subsequent hooks. All instruct returns accumulate into a single message regardless of which hook produced them.

Transitive imports

Custom hook files can import local modules using relative paths:
// my-policies.js
import { isBlockedPath } from "./utils.js";
import { checkApproval } from "./approval-client.js";

customPolicies.add({
  name: "approval-gate",
  fn: async (ctx) => {
    if (ctx.toolName !== "Bash") return allow();
    const approved = await checkApproval(ctx.toolInput?.command, ctx.session?.sessionId);
    return approved ? allow() : deny("Approval required for this command");
  },
});
All relative imports reachable from the entry file are resolved. This is implemented by rewriting from "failproofai" imports to the actual dist path and creating temporary .mjs files to ensure ESM compatibility.

Hook event type filtering

Use match.events to limit when a hook fires:
customPolicies.add({
  name: "require-summary-on-stop",
  match: { events: ["Stop"] },
  fn: async (ctx) => {
    // Only fires when the session ends
    // ctx.session.transcriptPath contains the full session log
    return allow();
  },
});
Omit match entirely to fire on every event type.

Error handling and failure modes

Custom hooks are fail-open: errors never block built-in policies or crash the hook handler.
FailureBehavior
customPoliciesPath not setNo custom hooks run; built-ins continue normally
File not foundWarning logged to ~/.failproofai/hook.log; built-ins continue
Syntax/import errorError logged to ~/.failproofai/hook.log; all custom hooks skipped
fn throws at runtimeError logged; that hook treated as allow; other hooks continue
fn takes longer than 10sTimeout logged; treated as allow
To debug custom hook errors, watch the log file:
tail -f ~/.failproofai/hook.log

Full example: multiple hooks

// my-policies.js
import { customPolicies, allow, deny, instruct } from "failproofai";

// Prevent agent from writing to secrets/ directory
customPolicies.add({
  name: "block-secrets-dir",
  description: "Prevent agent from writing to secrets/ directory",
  match: { events: ["PreToolUse"] },
  fn: async (ctx) => {
    if (!["Write", "Edit"].includes(ctx.toolName ?? "")) return allow();
    const path = ctx.toolInput?.file_path ?? "";
    if (path.includes("secrets/")) return deny("Writing to secrets/ is not permitted");
    return allow();
  },
});

// Keep the agent on track: verify tests before committing
customPolicies.add({
  name: "remind-test-before-commit",
  description: "Keep the agent on track: verify tests pass before committing",
  match: { events: ["PreToolUse"] },
  fn: async (ctx) => {
    if (ctx.toolName !== "Bash") return allow();
    const cmd = ctx.toolInput?.command ?? "";
    if (/git\s+commit/.test(cmd)) {
      return instruct("Verify all tests pass before committing. Run `bun test` if you haven't already.");
    }
    return allow();
  },
});

// Prevent unplanned dependency changes during freeze
customPolicies.add({
  name: "dependency-freeze",
  description: "Prevent unplanned dependency changes during freeze period",
  match: { events: ["PreToolUse"] },
  fn: async (ctx) => {
    if (ctx.toolName !== "Bash") return allow();
    const cmd = ctx.toolInput?.command ?? "";
    const isInstall = /^(npm install|yarn add|bun add|pnpm add)\s+\S/.test(cmd);
    if (isInstall && process.env.DEPENDENCY_FREEZE === "1") {
      return deny("Package installs are frozen. Unset DEPENDENCY_FREEZE to allow.");
    }
    return allow();
  },
});

export { customPolicies };

Examples in the repository

The examples/ directory contains ready-to-run hook files:
FileContents
examples/policies-basic.jsFive starter policies covering common agent failure modes
examples/policies-advanced/index.jsAdvanced patterns: transitive imports, async calls, output scrubbing, and session-end hooks
Install the basic examples:
failproofai policies --install --custom ./examples/policies-basic.js