Skip to content
Note

Why your agent hooks don't come with you

Importers carry over your rules, skills and MCP servers but not your hooks. Why hooks don't port between coding agents, and how to move them by hand.

· 7 min read · SetupBay

Moving an agent setup from one tool to another is getting easier. Hermes Agent now has an official importer that reads a Claude Code, Codex, Cursor or OpenClaw setup and brings it across. We asked whether hooks come too, and @HermesAgentTips gave a straight answer: instructions, skills, Bash permission rules and MCP servers come over. Hooks don’t. The importer’s own docs agree. Hooks aren’t in any of its mapping tables.

That’s no knock on Hermes. Hooks are the hardest part of a setup to translate, for reasons that apply to every tool. They’re also often the part that matters most, so it’s worth knowing why they stay behind and what it takes to move them yourself.

Rules are advice, hooks are code

Everything else an importer moves is text the model reads: an AGENTS.md, a skill, a memory. The model weighs it against everything else in its context and usually follows it. Usually. A long session, a confusing task or an instruction hidden in a file it reads can talk it out of a rule.

A hook isn’t read by the model at all. It’s a script the agent’s harness runs at a fixed point, such as before every shell command, after every file edit or when the agent says it’s done. The script gets the details as JSON, and if it says no, the command doesn’t run. The model can’t argue with it, because it never gets a say.

So hooks are where people put the rules that must hold: no force-pushes, no reading .env, no deleting outside the project, no “done” while the tests are red. Our hooks guard rails loadout is three of them for Claude Code. Our Terraform plan-only loadout uses one to refuse apply and destroy, and definition of done with proof offers a Stop hook that won’t let the agent finish if a test was quietly weakened.

They aren’t a sandbox, and the docs say so. Codex’s hooks page calls tool hooks “a useful guardrail, not a complete enforcement boundary”, and Claude Code’s warns that its if filter is best-effort. But a hook that fires holds in a way a sentence in AGENTS.md never will. Lose it in a move and the rule quietly goes back to being advice.

Why they don’t port

Every tool that has hooks invented its own version, so a hook that works in one is wrong in another in at least four ways.

Where the config lives. Claude Code reads .claude/settings.json. Cursor has its own .cursor/hooks.json, with a "version": 1 key. Codex uses .codex/hooks.json or a [hooks] table in config.toml. Gemini CLI puts them in .gemini/settings.json. Hermes reads a hooks: block in your profile’s ~/.hermes/config.yaml.

What things are called. “Before a shell command” is PreToolUse with a Bash matcher in Claude Code and Codex, beforeShellExecution (or preToolUse matching Shell) in Cursor, BeforeTool matching run_shell_command in Gemini CLI, and pre_tool_call matching terminal in Hermes. File edits differ too: Edit|Write in Claude Code, apply_patch in Codex, afterFileEdit in Cursor, write_file|replace in Gemini CLI, write_file|patch in Hermes.

What the script gets and must say back. The command arrives as tool_input.command in most of them, but at the top level as command in Cursor’s beforeShellExecution. To refuse, Claude Code and Codex take permissionDecision: "deny" inside hookSpecificOutput, Cursor takes "permission": "deny", Gemini CLI takes "decision": "deny" and Hermes takes {"action": "block"}. Even an empty reply means different things. Codex and Claude Code treat it as “no opinion”, but Cursor’s docs say a permission hook whose output isn’t valid JSON blocks the action, which is why its examples always print {"permission": "allow"}.

What happens when the hook breaks. This is the one that bites. A guard that crashes or times out lets the command through by default in all five. Claude Code can flip that with onFailure: "block", Cursor with failClosed: true and Hermes with fail_closed: true. The Codex and Gemini CLI docs don’t describe a switch like that for command hooks. Timeouts don’t even share a unit: seconds everywhere except Gemini CLI, which counts in milliseconds. Copy "timeout": 30 across and your hook gets 30 milliseconds.

Here’s the side by side for a guard on shell commands, from each tool’s docs as of 26 October 2026:

Project config Event and matcher Blocks with Fails closed with Timeout unit
Claude Code .claude/settings.json PreToolUse, Bash exit 2, or permissionDecision: "deny" onFailure: "block" seconds
Codex .codex/hooks.json PreToolUse, Bash exit 2, or permissionDecision: "deny" not documented seconds
Cursor .cursor/hooks.json beforeShellExecution, or preToolUse with Shell exit 2, or "permission": "deny" failClosed: true seconds
Gemini CLI .gemini/settings.json BeforeTool, run_shell_command exit 2, or "decision": "deny" not documented milliseconds
Hermes ~/.hermes/config.yaml (per profile, not per project) pre_tool_call, terminal exit 2, or {"action": "block"} fail_closed: true seconds

The bridges that exist

It isn’t all bad news. Claude Code’s format has become the one others copy, and that helps.

Cursor reads Claude Code hooks straight from .claude/settings.json when its third-party import setting is on, which it is by default. It maps the event names (UserPromptSubmit becomes beforeSubmitPrompt, and so on) and the tool names (Bash becomes Shell, Edit becomes Write), and it understands the nested hookSpecificOutput replies. It doesn’t support Notification or PermissionRequest hooks, and it has no mapping for Glob. Its page doesn’t mention onFailure either, so don’t assume a fail-closed Claude hook stays fail-closed in Cursor.

Codex took Claude Code’s event names and reply shape, so a Claude PreToolUse script usually runs as it is, as long as you register it in Codex’s own file. Gemini CLI and Cursor both set CLAUDE_PROJECT_DIR for compatibility, and Hermes shell hooks accept Claude-style {"decision": "block"} replies and the failClosed spelling.

So the scripts often travel better than the config. That’s the way to think about a move: the script is the rule, and the config is the wiring you redo for each tool.

Porting a hook by hand

  1. List what you have. In Claude Code, /hooks shows every hook and where it came from. Or read them directly with jq: jq '.hooks' .claude/settings.json ~/.claude/settings.json. Don’t forget hooks that came with plugins or sit in skill front matter.

  2. Sort them. Guards that block things come first. Checks after edits and stop gates come next. Hooks that only log or add context can wait.

  3. Make the script neutral. Read the command from either place, and block with exit 2 and a reason on stderr. Exit 2 is the one signal all five tools document for blocking a tool call, so going by their docs, a Bash script like this can serve as the guard in each of them:

    #!/usr/bin/env bash
    input=$(cat)
    cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // .command // empty')
    case "$cmd" in
      *"git push"*--force*|*"git push -f"*)
        echo "Blocked: force-push rewrites shared history. Push a new commit instead." >&2
        exit 2 ;;
    esac
    exit 0

    Then check the allow path. In Cursor, print {"permission": "allow"} before exit 0 if an empty reply blocks in your version.

  4. Wire it up in each tool. Use the event, matcher, path and timeout unit from the table above. Use absolute paths, or paths from the project root. Cursor runs project hooks from the root, Codex runs them from the session’s working folder, and its docs suggest $(git rev-parse --show-toplevel) for that reason.

  5. Fail closed where you can. Set onFailure, failClosed or fail_closed on every guard. Where a tool has no such switch, keep the script short and keep a matching deny rule in its permission settings, so a broken hook isn’t the only line of defence.

  6. Approve it. Codex won’t run a new or changed hook until you trust it in /hooks. Gemini CLI warns before running a project hook whose command has changed. Hermes asks the first time each hook runs, and Cursor only runs project hooks in a trusted workspace. An imported hook you haven’t approved does nothing.

  7. Prove it. In each tool, ask the agent to do the forbidden thing in a scratch repo and watch it get refused. A good first step is a canary rule that blocks something harmless, such as echo hook-canary. If the canary runs, nothing else in that hook is working either.

  8. Keep the sentence too. Put a line in your shared AGENTS.md saying what the hooks block and that a refusal means stop and ask, not rephrase. Our one AGENTS.md for every agent loadout keeps that file in one place for every tool, and Cursor rules that load covers when something belongs in a rule and when it needs a hook.

One last thing. A hook runs with your full user permissions, so an imported one deserves the same suspicion as an imported skill. Read the script before you trust it, especially one that came from someone else’s dotfiles or a plugin. Our vetting loadout treats it like any other dependency.

Hooks take more effort to move than a rules file. But they’re what makes the rules stick, so they’re worth the extra half hour.