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
| Function | Effect | Use when |
|---|
allow() | Permit the tool call | The action is safe to proceed |
deny(message) | Block the tool call | The agent should not take this action |
instruct(message) | Add context to Claude’s prompt | Give 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
| Field | Type | Description |
|---|
eventType | string | "PreToolUse", "PostToolUse", "Notification", "Stop" |
toolName | string | undefined | The tool being called (e.g. "Bash", "Write", "Read") |
toolInput | Record<string, unknown> | undefined | The tool’s input parameters |
payload | Record<string, unknown> | Full raw event payload from Claude Code |
session | SessionMetadata | undefined | Session context (see below) |
| Field | Type | Description |
|---|
sessionId | string | Claude Code session identifier |
cwd | string | Working directory of the Claude Code session |
transcriptPath | string | Path to the session’s JSONL transcript file |
Event types
| Event | When it fires | toolInput contents |
|---|
PreToolUse | Before Claude runs a tool | The tool’s input (e.g. { command: "..." } for Bash) |
PostToolUse | After a tool completes | The tool’s input + tool_result (the output) |
Notification | When Claude sends a notification | { message: "...", notification_type: "idle" | "permission_prompt" | ... } - hooks must always return allow(), they cannot block notifications |
Stop | When the Claude session ends | Empty |
Evaluation order
Hooks are evaluated in this order:
- Built-in policies (in definition order)
- 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.
| Failure | Behavior |
|---|
customPoliciesPath not set | No custom hooks run; built-ins continue normally |
| File not found | Warning logged to ~/.failproofai/hook.log; built-ins continue |
| Syntax/import error | Error logged to ~/.failproofai/hook.log; all custom hooks skipped |
fn throws at runtime | Error logged; that hook treated as allow; other hooks continue |
fn takes longer than 10s | Timeout 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:
| File | Contents |
|---|
examples/policies-basic.js | Five starter policies covering common agent failure modes |
examples/policies-advanced/index.js | Advanced patterns: transitive imports, async calls, output scrubbing, and session-end hooks |
Install the basic examples:
failproofai policies --install --custom ./examples/policies-basic.js