DEV Community

Cover image for Approve Claude Code permission prompts from anywhere with the PermissionRequest hook
Fred 🍓
Fred 🍓

Posted on

Approve Claude Code permission prompts from anywhere with the PermissionRequest hook

A few days ago I wrote about the desktop I built around Claude Code because I kept missing its permission prompts. In the comments someone asked the question I like most: do you detect "waiting" by reading the terminal, or from hooks?

Hooks. And one hook in particular, PermissionRequest, turned out to be much more useful than I expected. You don't need my app for any of this, so here's how it works and what bit me on the way.

The hook

Claude Code runs your command whenever it is about to show a permission prompt. Your command gets JSON on stdin, roughly:

{
  "hook_event_name": "PermissionRequest",
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" },
  "permission_suggestions": [ ... ]
}
Enter fullscreen mode Exit fullscreen mode

and it can answer on stdout:

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": { "behavior": "allow" }
  }
}
Enter fullscreen mode Exit fullscreen mode

behavior is allow or deny (a deny can carry a message, which Claude reads and reacts to). If you print nothing, Claude Code just shows its normal prompt. That last part is what makes the hook safe to experiment with: you only decide the cases you're sure about.

A tiny example you can use today

Allow a few harmless commands, block one obviously bad one, stay out of everything else:

// approve.js – PermissionRequest hook
let input = ''
process.stdin.on('data', (c) => (input += c))
process.stdin.on('end', () => {
  let cmd = ''
  try {
    const p = JSON.parse(input)
    if (p.tool_name === 'Bash') cmd = String(p.tool_input?.command ?? '')
  } catch {}

  const reply = (decision) =>
    process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }))

  if (/^(git (status|diff|log)|ls|pwd|npm test)\b/.test(cmd) && !/[;&|>]/.test(cmd)) reply({ behavior: 'allow' })
  else if (/\brm\s+-rf\s+[\/~]/.test(cmd)) reply({ behavior: 'deny', message: 'Not deleting from / or ~. Ask me first.' })
  // anything else: print nothing -> normal prompt
})
Enter fullscreen mode Exit fullscreen mode

And in .claude/settings.json:

{
  "hooks": {
    "PermissionRequest": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node /path/to/approve.js", "timeout": 10 }] }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

(Yes, allow/deny rules in settings cover a lot of this too. The hook gets interesting when the decision needs logic, or a human somewhere else.)

Answering from another window, or your phone

This is the part I actually built it for. The hook doesn't have to answer right away. It can just wait.

In VibeDesk every session gets a settings file that points all hook events to a small forwarder script. For PermissionRequest the forwarder POSTs the JSON to the app on 127.0.0.1 and keeps the request open. The app shows a card with the command and Allow / Always / Deny / In terminal, on the desktop and on a paired phone. Whatever you tap becomes the HTTP response, the forwarder prints it, Claude Code continues. Nothing has to type y into a PTY. (Claude Code still shows its normal prompt in the terminal while the hook waits, so you can answer in either place.)

"Always" sends back the permission_suggestions Claude Code offered as updatedPermissions, so it's the same as picking "don't ask again" in the terminal.

Things that bit me

1. The hook must never break Claude. It runs on every single prompt. If it crashes, hangs or exits with an error, you've made Claude Code worse. My forwarder always exits with code 0, has a hard timer, and if the app isn't running it just exits silently, so Claude falls back to the normal prompt.

2. Timeouts. Claude Code gives a command hook 600 seconds by default, which is plenty for a human. The catch is the other direction: my forwarder runs on every event, so I set all other events to 5 s (a stuck app must never slow Claude down) and only PermissionRequest keeps 600. The script gives up by itself at 595 s, so it always returns before Claude Code kills it. Otherwise you get a confusing state instead of a clean fallback.

3. People answer in the terminal anyway. Sometimes you're already looking at that window and just press enter. Then the card in the app is stale. The fix: when PostToolUse arrives for the same tool and the same input, close the open card. Same for UserPromptSubmit, Stop and SessionEnd.

4. Restarts. After an app update the port and token change, but sessions are still running with the old ones in their environment. The forwarder now re-reads a small endpoint file when it gets a 403 and retries once.

5. Don't parse the terminal. My first version looked for the prompt text in the output. It broke on every small UI change. Hooks are the stable interface, use them.

Detecting "waiting" in general

PermissionRequest tells you a session is blocked on you, and it fires right away. Notification also fires for permission waits, but only after a few seconds (and not always), so don't rely on it for that. Stop tells you a turn is done. With just those three you can drive a status light, a Stream Deck key, a tray icon or a phone notification. That's all the yellow dot in my app really is.


VibeDesk is free and MIT licensed (Windows): https://github.com/fxd-gif/vibedesk. Version 2.0 comes out on Sunday, Nov 1, with session wrap-ups, tray status and sessions that can hand tasks to each other. If you build something with this hook, I'd love to see it in the comments.

And here's the short teaser for 2.0:

Top comments (0)