"Deployed, it's live." Claude Code can say that right after the deploy command, with nothing checked since. It runs the command, sees output scroll by, and announces success without opening the URL.

This is the mistake I find hardest to catch, because it arrives at the end of the turn, when you're ready to believe it.

The Stop hook

Claude Code has a Stop event: it fires when Claude is about to end its turn and hand control back to you. A Stop hook can refuse, and Claude has to continue with the reason you gave.

The hook reads the session transcript (its path arrives in the event), and asks two questions:

  1. Did a deploy command run during this turn? (vercel, wrangler deploy, netlify deploy, git push to a branch that deploys...)
  2. After that, did anything check the result? (curl on the URL, vercel inspect, a browser visit, a status call)

If the answer to the first is yes, to the second no, and the last message contains "live" or "deployed", the hook blocks the stop:

#!/usr/bin/env python3 import json, re, sys event = json.load(sys.stdin) if event.get("stop_hook_active"): sys.exit(0) lines = open(event["transcript_path"]).read().splitlines() commands, last_text = [], "" for line in lines: entry = json.loads(line) for block in (entry.get("message") or {}).get("content") or []: if isinstance(block, dict) and block.get("type") == "tool_use": commands.append(json.dumps(block.get("input"))) if isinstance(block, dict) and block.get("type") == "text": last_text = block.get("text", "") last_text = event.get("last_assistant_message") or last_text deploy = [i for i, c in enumerate(commands) if re.search(r"vercel|wrangler\s+deploy|netlify\s+deploy", c)] checked = deploy and any(re.search(r"curl|inspect|navigate", c) for c in commands[deploy[-1] + 1:]) if deploy and not checked and re.search(r"\b(live|deployed|en ligne)\b", last_text, re.I): print(json.dumps({"decision": "block", "reason": "You deployed but never checked the result. Curl the URL or " "read the deployment status before saying it is live."})) 

stop_hook_active prevents a loop: if Claude is already continuing because of this hook, let it stop.

last_assistant_message is the part I got wrong first. When I ran this in a real session, Claude's final message hadn't reached the transcript yet when the hook ran, so the hook never fired. The event carries that message, and the transcript is only a fallback.

The version above reads the whole transcript to stay short. The one I run limits itself to the current turn, so an old deploy from an hour ago doesn't trigger it.

What changed

When Claude forgets to check, the hook sends it back before I read anything, and I get "HTTP 200, the new title is there" instead of "it's live". When the deploy did fail, I find out in the same turn instead of from a client.

The turn-scoped version is free in seatbelt-lite. The paid Seatbelt also counts git push and npm run deploy as deploys.