← Blog/23 October 2026 · 4 min read

Using Claude Code hooks to open a page while your AI agent works

Claude Code can run a shell command when you send a prompt and when it finishes. We used those two hooks to tell a web app when the agent is busy, open a browser tab only when needed, and stay silent inside the agent's context.

Claude CodeDeveloper toolsHooksNode.js

When you hand a task to an AI coding agent, there is a stretch of time, sometimes ten seconds, sometimes ten minutes, when you are just waiting. We built a small web app for that gap. It shows a "working" or "done" state, and it needs to know, in real time, when the agent starts and stops.

Claude Code makes this possible with hooks: shell commands that run on specific events. This post covers how we wired two of them, and the three details that took us longest to get right.

The two events we need

Hooks are configured in ~/.claude/settings.json. Each event holds a list of commands:

  • UserPromptSubmit runs when you send a prompt, which is our "start" signal.
  • Stop runs when the agent finishes responding, which is our "stop" signal.

Each hook posts a tiny JSON message to our server with the event type and the user's personal token:

curl -s -m 3 -X POST https://example.com/api/event \
  -H 'Content-Type: application/json' \
  -d '{"type":"start","token":"<token>"}'

The -m 3 matters. A hook that hangs blocks the agent, so every network call gets a hard three-second timeout, and every command ends in || true so a failure never surfaces as an error.

Detail one: stay silent

For most hook events, output just goes to a log. UserPromptSubmit is different: whatever the command prints to stdout is added to the agent's context, as if it were part of your prompt. A chatty hook would quietly put "ok" or a JSON blob into every conversation.

So every command redirects its output, and the install script writes them carefully:

r=$(curl ... 2>/dev/null)
[ "$r" = open ] && { open "$URL" || xdg-open "$URL" || cmd.exe /c start '' "$URL"; } >/dev/null 2>&1
true

The final true makes sure the hook exits successfully even when the test is false, and nothing is ever printed.

Detail two: only open a tab when there isn't one

Opening a new browser tab on every single prompt would be maddening. The decision of whether to open one is made on the server, because only the server knows whether the user already has the page open.

Each open tab sends a small heartbeat every few seconds. The server records the time of the last heartbeat for that user. When a start event arrives, it answers with one word:

  • open if no tab has checked in during the last 20 seconds, and it has not asked the hook to open one in the last 10 minutes;
  • ok otherwise.

The hook only launches the browser when it reads open. The ten-minute throttle covers the case where the user deliberately closed the tab; we do not want to keep pushing it back at them.

The opening command tries open (macOS), then xdg-open (Linux), then cmd.exe /c start (Windows under Git Bash), so one hook definition works on all three.

Detail three: put the token in the body

Our first version sent the token in an Authorization: Bearer header, which is the conventional place for it. It worked locally and failed on our shared host. Many Apache setups that run PHP through CGI or FastCGI drop the Authorization header before PHP ever sees it.

Moving the token into the JSON body fixed it everywhere. The server still accepts the header too, for hosts that pass it through. It is the kind of thing you only learn in production, so we left a comment in the installer explaining why.

Installing without breaking other hooks

People already have their own hooks, and an installer that overwrites settings.json would destroy them. Ours is a short Node script that:

  • reads the existing settings file, or starts from an empty object;
  • removes only the hooks it added previously, which it recognises by a marker comment (# meanwhile-ping) at the end of each command;
  • adds the new hooks at the front of each event's list;
  • writes the file back, leaving everything else untouched.

Because it removes its own old entries first, running the installer twice is harmless. It also validates the token format before writing anything, so a mistyped command cannot leave a broken hook behind.

s.hooks[event] = (s.hooks[event] || [])
  .map(g => ({ ...g, hooks: (g.hooks || []).filter(h => !String(h.command).includes(TAG)) }))
  .filter(g => g.hooks.length);
s.hooks[event].unshift({ hooks: [{ type: 'command', command: cmd(type) }] });

Security notes

The token in the hook identifies one user and can only report "start" or "stop" for that user. It cannot read data, change settings or earn anything. Even so, it lives only in the user's own settings file, and the server compares tokens with hash_equals to avoid timing leaks. If a token is ever exposed, issuing a new one makes the old hooks fail quietly instead of noisily.

Summary

Two hooks, UserPromptSubmit and Stop, are enough to connect an AI coding agent to the outside world. Keep every hook fast, silent and impossible to fail, let the server decide when to interrupt the user, and make the installer merge rather than overwrite. The whole integration is under thirty lines, and the agent never notices it is there.

Next article
Rewarded ads on the web: points, not cash, and limits instead of trust →