Claude Code Hooks for Trading Bots: Block Secrets, Gate Live Orders

KalshiPolymarket US Agent guardrails (no trading)

Short answer: Claude Code hooks are scripts that run at fixed points in Claude Code's loop. For a trading bot, a PreToolUse hook can block the agent from reading .env or key files, starting the bot with --live, or running git push: the hook exits with code 2 and its stderr goes back to Claude. Pair hooks with permissions.deny rules, because hooks only govern the AI agent, not your running bot.

What are the key facts?

  • Hooks live in .claude/settings.json (project, commit it), .claude/settings.local.json (project, personal) or ~/.claude/settings.json (all projects). Hook entries from every level merge; they don't replace each other.
  • PreToolUse runs before a tool call and can block it. Your script gets JSON on stdin (tool_name, tool_input). Exit 2 blocks the call and sends stderr to Claude; exit 0 means no objection.
  • Exit code 1 does not block. It's a non-blocking error and the action goes ahead. For a policy hook, use exit 2.
  • By default a hook that crashes, can't start or times out lets the action through. Add "onFailure": "block" (Claude Code v2.1.295 or later) so a broken guard fails closed.
  • The docs call the if filter best-effort and say to use the permission system for a hard allow or deny. So keep permissions.deny rules for .env/*.pem reads alongside the hook.
  • Portable: Cursor can load Claude Code hooks, and Grok Build reads hooks from .claude/settings.json too. Codex uses its own .codex/hooks.json with the same PreToolUse + exit-code-2 idea.
DifficultyIntermediate
Time30–45 min
Cost$0
Works withClaude Code, Cursor, Grok Build

What will you build?

A .claude/ folder for your bot repo with a tested PreToolUse guard (secrets, live mode, git push, limit edits), a PostToolUse hook that re-runs your limit tests whenever the agent touches risk code, and deny rules as the hard backstop.

Who is it for?

People building a Kalshi or Polymarket US bot with Claude Code who want guardrails that don't depend on the model remembering a rules file.

Not for: Treating hooks as your money safety net. Position limits and the kill switch still live in the bot's own code.

What do the hook files look like?

Three files go in your bot repo. They assume the layout from our other recipes: bot.py, risk.py, a human-edited limits.json, and tests/test_limits.py.

1. .claude/settings.json: deny rules plus the two hooks. The args form runs the script directly with no shell, which is what the docs recommend when you use ${CLAUDE_PROJECT_DIR}.

{
  "permissions": {
    "deny": [
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/*.pem)",
      "Read(**/*.key)",
      "Bash(git push *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Read|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/bot_guard.py"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.venv/bin/python",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/limits_tests.py"],
            "timeout": 120
          }
        ]
      }
    ]
  }
}

On Claude Code older than v2.1.295, delete the onFailure line (and update soon, because without it a crashed guard fails open).

2. .claude/hooks/bot_guard.py: the PreToolUse guard. It never prints file contents or secrets, only a reason.

#!/usr/bin/env python3
"""BotCookery PreToolUse guard for Claude Code (also loads in Cursor and Grok Build).
Exit 0 = no objection (normal permissions still apply). Exit 2 = block; stderr goes to the agent."""
import json, re, sys

data = json.load(sys.stdin)
tool = data.get("tool_name", "")
inp = data.get("tool_input") or {}

# .env, .env.local, *.pem, *.key, SSH keys. .env.example is allowed.
SECRET = re.compile(r"(^|[/\s'\"=])(\.env(?!\.example)(\.[\w-]+)?|[\w.-]+\.pem|[\w.-]+\.key|id_rsa|id_ed25519)($|[\s'\";|&)])", re.I)

def block(msg):
    print(f"BotCookery guard: {msg}", file=sys.stderr)
    sys.exit(2)

if tool == "Bash":
    cmd = inp.get("command", "")
    if re.search(r"--live\b|BOT_MODE=live|KALSHI_ENV=prod", cmd):
        block("live trading is started by a human in their own terminal, never by the agent.")
    if SECRET.search(cmd):
        block("commands that touch .env or key files are blocked. The bot loads secrets from env vars itself.")
    if re.search(r"(^|[;&|]\s*)(printenv|env)(\s*$|\s*[|;&>])", cmd) or re.search(r"echo\s+[\"']?\$\{?\w*(KEY|SECRET|TOKEN|PASS)", cmd, re.I):
        block("printing environment secrets is blocked.")
    if re.search(r"\bgit\s+push\b", cmd):
        block("git push needs a human.")
    if "limits.json" in cmd and not re.match(r"\s*(cat|less|head|jq)\s", cmd):
        block("limits.json is human-edited only. Ask the user to change limits.")
elif tool in ("Read", "Edit", "Write"):
    path = inp.get("file_path", "")
    if SECRET.search(" " + path):
        block(f"access to {path} is blocked.")
    if tool != "Read" and re.search(r"(^|/)limits\.json$", path):
        block("limits.json is human-edited only. Ask the user to change limits.")
sys.exit(0)

3. .claude/hooks/limits_tests.py: the PostToolUse hook. A PostToolUse hook can't undo an edit, but exit 2 shows the test failure to Claude so it fixes the code right away.

#!/usr/bin/env python3
"""BotCookery PostToolUse hook: after the agent edits risk, limit or kill-switch code,
run the limit tests. Exit 2 sends the failure output back to the agent to fix."""
import json, os, re, subprocess, sys

data = json.load(sys.stdin)
path = (data.get("tool_input") or {}).get("file_path", "")
if not re.search(r"(risk|limit|kill)[\w-]*\.py$", path):
    sys.exit(0)
root = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
r = subprocess.run([sys.executable, "-m", "pytest", "-q", "tests/test_limits.py"],
                   cwd=root, capture_output=True, text=True, timeout=110)
if r.returncode != 0:
    print(f"Limit tests failed after editing {path}:\n{(r.stdout + r.stderr)[-3000:]}", file=sys.stderr)
    sys.exit(2)
sys.exit(0)

We ran both scripts against sample hook inputs before publishing: the guard blocks python bot.py --live, cat .env, env | grep KALSHI, echo $KALSHI_API_KEY_ID, git push, edits to limits.json and reads of *.pem files, and it allows python bot.py, pytest -q, cat .env.example and edits to risk.py. The test hook exits 2 when a limit test fails and 0 when it passes.

What prompt do you paste into your AI coding tool?

Works in Claude Code, Cursor, Codex/ChatGPT, Grok or any agent that can write files. Paste it into an empty folder. Read what it builds before you run anything.

Add Claude Code hooks to this bot repo. Use ONLY the hooks reference at
https://code.claude.com/docs/en/hooks and the permissions page at
https://code.claude.com/docs/en/permissions. If something isn't documented
there, say so instead of guessing.

1. Create .claude/settings.json with:
   - permissions.deny: Read(**/.env), Read(**/.env.*), Read(**/*.pem),
     Read(**/*.key), Bash(git push *)
   - a PreToolUse hook, matcher "Bash|Read|Edit|Write", exec form
     ("command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/bot_guard.py"]),
     timeout 10, onFailure "block"
   - a PostToolUse hook, matcher "Edit|Write", that runs
     .claude/hooks/limits_tests.py with the project's .venv python
2. bot_guard.py (stdlib only): read the hook JSON from stdin. Block with
   exit code 2 and a one-line reason on stderr when:
   - a Bash command contains --live, BOT_MODE=live or KALSHI_ENV=prod
   - a Bash command or a Read/Edit/Write path touches .env (not
     .env.example), *.pem, *.key or SSH keys
   - a Bash command prints env vars (printenv, env, echo $..._KEY)
   - a Bash command runs git push
   - anything except a read touches limits.json
   Otherwise exit 0. Never print file contents or secret values.
3. limits_tests.py: if the edited file matches (risk|limit|kill)*.py, run
   pytest -q tests/test_limits.py from $CLAUDE_PROJECT_DIR; on failure print
   the tail of the output to stderr and exit 2.
4. tests/test_hooks.py: feed sample JSON into bot_guard.py with subprocess
   and assert the exit codes (blocked: "python bot.py --live", "cat .env",
   "git push"; allowed: "pytest -q", "cat .env.example").
Do not change bot.py, risk.py or limits.json. Show me the files, then run
the hook tests.

How do you build it, step by step?

  1. Start from a bot repo that already has limits in code. If you don't have one yet, cook the Kalshi AI trading bot or Polymarket paper bot first, plus the kill switch recipe.
  2. Paste the prompt into Claude Code, or copy the three files from the section above.
  3. Make the scripts executable if you switch to shell form: chmod +x .claude/hooks/*.py. In the args form shown here, Claude Code runs python3 script.py directly, so it isn't required.
  4. Check that Claude Code sees the hooks. Type /hooks in Claude Code. It's a read-only browser that lists each hook and the file it came from.
  5. Run the hook tests: pytest -q tests/test_hooks.py. They pipe sample JSON into the guard, the same way Claude Code does.
  6. Try to break it on purpose. Ask Claude Code to "run the bot live" or "show me what's in .env". You should see the guard's reason come back as a blocked tool call.
  7. Commit .claude/settings.json and .claude/hooks/ so the guard travels with the repo. Keep personal tweaks in .claude/settings.local.json.

How do you test it in paper or demo mode first?

Hooks don't touch your bot's trading mode. They decide what the AI agent may do while it works on the code. Keep the bot itself on Kalshi demo or local paper mode exactly as in the bot recipes. The guard adds one more rule: the agent can never be the one that starts live mode. A human types --live in their own terminal, after the paper logs look clean.

A good test run: let Claude Code work on the bot for a session in paper mode, then read your hook outcomes. Blocked calls show up in the transcript with the guard's reason, which tells you what the agent tried to do.

What safety checks does it need before real money?

Tick every box before the bot touches real money. The full rules file, .gitignore and pre-commit hook are in the Safety Pack.

Which official docs did we check?

Endpoints and rules were checked against these pages on Oct 19, 2026. APIs change, so if something here disagrees with the official docs, trust the docs and tell us.

Frequently asked questions

What are Claude Code hooks?

User-defined handlers (shell commands, HTTP endpoints, MCP tools, prompts or subagents) that Claude Code runs automatically at points in its lifecycle, such as before or after a tool call. They're configured in Claude Code settings files.

How do I block a command with a Claude Code hook?

Add a PreToolUse hook with a matcher like Bash. Your script reads the tool call as JSON on stdin and either exits 2 with a reason on stderr, or prints JSON with permissionDecision: "deny". Exit 0 with no output means no objection, and normal permissions still apply.

Where do Claude Code hooks go?

In ~/.claude/settings.json (all your projects), .claude/settings.json (one project, shareable via git) or .claude/settings.local.json (one project, personal). Plugins, skills and subagents can also define hooks.

Can a hook stop my bot from losing money?

No. Hooks control what the AI coding agent does, not what your running bot does. Losses are prevented by limits in the bot's code, a kill switch, venue cancel-all endpoints and starting in demo or paper mode.

Do Claude Code hooks work in Cursor or Grok?

Cursor's docs say it can load hooks from Claude Code. Grok Build reads hooks from .claude/settings.json (project hooks need folder trust). Codex has its own hooks.json with a very similar PreToolUse format, including exit code 2 to block.

What happens if my hook script crashes?

By default the action goes ahead, because a crash counts as a non-blocking error. Set "onFailure": "block" on the hook (Claude Code v2.1.295 or later) to block the action instead.

Related recipes