@pivanov/agent-hooks-bridge
v0.1.2
Published
One hook script, every agent. Translates between Claude Code, Cursor, and Codex hook schemas.
Maintainers
Readme
@pivanov/agent-hooks-bridge
One hook script, every agent. Translates between Claude Code, Cursor, and Codex hook schemas at runtime.
Each agent ships its own incompatible hook system. This package lets you write one hook that runs across all three.
- Zero runtime dependencies. TypeScript. MIT.
- ESM-only. Bun and Node 22+.
- Subpath exports per host (
/claude,/cursor,/codex) for tree-shaking. - Library API. No global CLI.
Install
bun add @pivanov/agent-hooks-bridgeQuick start
Write one hook in your repo:
#!/usr/bin/env bun
// .hooks/format.ts
import { defineHook, run } from "@pivanov/agent-hooks-bridge";
const hooks = defineHook({
PreToolUse: (event) => {
if (event.tool === "Bash" && /rm -rf/.test(String(event.tool_input.command ?? ""))) {
return { decision: "deny", reason: "rm -rf is blocked by policy" };
}
return { decision: "allow" };
},
PostToolUse: async (event) => {
if (event.tool !== "Edit" && event.tool !== "Write") {
return { decision: "allow" };
}
const file = event.tool_input.file_path;
if (typeof file !== "string" || !/\.(ts|tsx|js|jsx)$/.test(file)) {
return { decision: "allow" };
}
await Bun.spawn(["biome", "check", "--write", file]).exited;
return { decision: "allow", additional_context: `biome formatted ${file}` };
},
});
await run(hooks);chmod +x .hooks/format.ts, then wire it into all three host configs:
bunx @pivanov/agent-hooks-bridge install ./.hooks/format.tsThis generates or merges:
.claude/settings.json.cursor/hooks.json.codex/hooks.toml
Each entry runs the same script with --host <host> so the runtime knows which adapter to use without sniffing stdin.
Supported events
| Unified | Claude | Cursor | Codex |
| ------------------ | ------------------ | -------------------------------------------------------------------------- | ------------------ |
| SessionStart | SessionStart | sessionStart | SessionStart |
| PreToolUse | PreToolUse | preToolUse, beforeShellExecution, beforeMCPExecution, beforeReadFile | PreToolUse |
| PostToolUse | PostToolUse | postToolUse, afterShellExecution, afterMCPExecution, afterFileEdit | PostToolUse |
| UserPromptSubmit | UserPromptSubmit | beforeSubmitPrompt | UserPromptSubmit |
| Stop | Stop | stop | Stop |
Tool aliasing on PreToolUse / PostToolUse:
| Unified event.tool | Claude | Cursor | Codex |
| -------------------- | ---------------- | ------------------------------- | ------------- |
| Bash | Bash | shell events | Bash |
| Edit | Edit | file edit events | apply_patch |
| Write | Write | file edit events | apply_patch (collapsed to Edit) |
| Read | Read | beforeReadFile | Read |
| MCP | passthrough | MCP events | passthrough |
Cursor's specific events fold upward into the unified shape. The native payload is preserved at event._native.
Codex collapses apply_patch to event.tool === "Edit" regardless of whether it's a create or an update, because the native event carries no distinction. To disambiguate, branch on event.tool_input.patch (Codex emits a single *** Update File: / *** Add File: directive per call) or read event.native_tool (always set to the host's original tool name).
Response shape
interface IHookResponse {
decision?: "allow" | "deny" | "ask";
reason?: string; // model-facing message
user_message?: string; // human-facing message (honored on Cursor permission events)
modified_input?: object; // tool-input mutation (Claude PreToolUse, Cursor preToolUse)
additional_context?: string; // model context injection
}Fields a host can't honor are dropped with a stderr warning naming the field, host, and event.
Per-host capability matrix
| Field | Claude | Cursor | Codex |
| -------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| decision: "allow" | yes | yes (permission events; continue: true on beforeSubmitPrompt) | yes |
| decision: "deny" | yes (exit 2) | yes (exit 2) | yes (exit 2) |
| decision: "ask" | yes | downgraded to deny on Cursor 2.4.21+ (still broken on 3.2.16; pass cursorAskFallback: "ask" to opt out) | yes |
| reason | permissionDecisionReason / reason | agent_message on permission events; dropped on beforeSubmitPrompt | permissionDecisionReason / reason |
| user_message | dropped | user_message on permission events | dropped |
| additional_context | SessionStart, PreToolUse, UserPromptSubmit, PostToolUse | sessionStart, postToolUse; dropped on beforeSubmitPrompt | SessionStart, UserPromptSubmit, PostToolUse; systemMessage on Stop |
| modified_input | PreToolUse (as updatedInput); dropped elsewhere | preToolUse (as updated_input); dropped on specific events | dropped |
Cursor beforeSubmitPrompt
Cursor's prompt-submit response is { continue, user_message } only. additional_context returned on UserPromptSubmit works on Claude and Codex but is dropped on Cursor. Use SessionStart for project-level briefings or PostToolUse for between-turn context.
API
import { defineHook, run } from "@pivanov/agent-hooks-bridge";defineHook(handlers)
Type-checked builder for the event handler map. Identity at runtime.
run(handlers, options?)
Reads stdin, identifies the host, parses the native payload into a unified event, calls your handler, serializes the response, exits with the right code.
interface IRunOptions {
host?: "claude" | "cursor" | "codex";
stdin?: string;
argv?: readonly string[];
exit?: (code: number) => never;
write?: (chunk: string) => void;
maxBytes?: number;
}host precedence: explicit host option, then --host <id> in argv, then stdin detection.
Exit codes
0: proceed (allow, ask, no decision)2: block (decision: "deny")1: bridge error (parse failure, unknown host, handler exception)
Subpath exports
For embedders who plug a single adapter into a custom transport:
import { claudeAdapter } from "@pivanov/agent-hooks-bridge/claude";
const event = claudeAdapter.parse(rawStdin);
const native = claudeAdapter.serialize({ decision: "deny" }, event);Adapter contract:
interface IHostAdapter {
readonly id: "claude" | "cursor" | "codex";
readonly capabilities: ReadonlySet<TUnifiedEventName>;
parse(raw: Record<string, unknown>): TUnifiedEvent;
serialize(response: IHookResponse, event: TUnifiedEvent): INativeResponse;
}install
bunx @pivanov/agent-hooks-bridge install <script> [options]
Options:
--events <list> comma-separated unified events (default: all 5)
--hosts <list> comma-separated hosts (default: claude,cursor,codex).
Pass 'auto' to write only to hosts whose config dir
exists on this machine.
--matcher <pattern> tool matcher for PreToolUse / PostToolUse (default: *)
--dry-run print the config diff without writing
--cwd <path> install relative to this directory
--global, -g write to each host's user-level config:
claude ~/.claude/settings.json
cursor ~/.cursor/hooks.json
codex $CODEX_HOME/hooks.toml (default ~/.codex/)
Overrides --cwd and --config-root.
--config-root treat <cwd> as the host's config dir directly (escape
hatch for layouts like ~/.claude-main, ~/.claude-ship)
--help, -h show usageRe-runs are idempotent: only entries pointing at the same script are replaced. Other entries in the same config file are preserved. --dry-run prints the would-be configs to stdout; nothing on disk changes.
For Codex TOML, the bridge writes a marker block (# >>> agent-hooks-bridge … # <<< agent-hooks-bridge); re-installs replace the block in place.
To remove the entries:
bunx @pivanov/agent-hooks-bridge uninstall ./.hooks/format.tsuninstall (and doctor) accept the same --hosts, --cwd, --global, and --config-root flags. Only entries whose command matches the given script (with or without a trailing --host <id>) are removed. Other entries in the same config are preserved. Empty event arrays are dropped; for Codex, the managed block is removed in place.
Troubleshooting
Bridge diagnostics go to stderr, prefixed with [agent-hooks-bridge].
'<field>' is not supported on <host>'s <event> response; dropped
Your handler returned a field the host can't surface for that event. Common cases:
additional_contexton Cursor'sbeforeSubmitPrompt: Cursor's prompt-submit response is allow/deny only. Move toSessionStart.user_messageon Claude or Codex: those hosts have no human-facing channel. Usereason(model-facing).modified_inputon Cursor's specific events: only the genericpreToolUseacceptsupdated_input.
cursor <version>: 'permission: "ask"' is not honored ...; downgrading to 'deny'
Cursor regression that started in 2.4.21 and has not been fixed as of 3.2.16 (last verified). The visible behavior changed across the regression window: 2.4.21 through 2.x silently treats ask as deny; 3.x silently treats it as allow. Either way the hook author's "ask the user" intent is dropped on the floor. The bridge reads cursor_version from stdin and downgrades to deny automatically because that's the safer default on both eras.
To opt out and let ask reach Cursor unchanged (it will still be silently mishandled, but the bridge stays out of the way):
await run(hooks, { cursorAskFallback: "ask" });could not detect host from stdin (scores: {...}); pass --host or { host } explicitly
Detection scored below the 0.5 threshold. install writes --host <host> into each generated config, so this usually only fires for hand-wired hooks. Pass --host in the host config or run(hooks, { host: "claude" }) programmatically.
handler for '<event>' threw: <message>
Your handler raised. Exit code 1. The host treats this as a non-blocking error.
'<host>' adapter failed to parse: <message>
Stdin had the right shape for the chosen host but a required field was missing or the wrong type.
License
MIT © Pavel Ivanov
