Skip to content

Approval gates

Three related mechanisms, from “the agent asks nicely” to “the human imposes it”:

Terminal window
pidge approval --title "Deploy to production?"
# → blocks; chosen_action.action_id is "grant" (approved, behind Face ID) or "deny"

A recipe over important: Approve (Face ID) / Reject buttons + --wait. Use it when your code wants the decision as JSON to parse. Pass your own --actions to override the pair.

One more outcome to handle: action_id can come back "done" — a neutral, terminal Done the server appends to every ask (v93), meaning the human cleared the pendency without deciding. Treat it as not approved (it’s not a grant), stop waiting, and don’t retry the same ask.

2. pidge approve — the exit-code gate for hooks

Section titled “2. pidge approve — the exit-code gate for hooks”
Terminal window
pidge approve "Run the schema migration?" --body "Drops legacy_orders" --timeout 300

Built for permission hooks that must fail closed. The answer is the exit code:

Exit Meaning
0 explicit allow (Face ID tap) — the ONLY success
1 deny, timeout, no answer, broken channel, or an HTTP failure on the send
2 only a raw network error — the send never reached the server

Non-zero always means “not approved.” It never exits 3/4 like the other waiting commands — ambiguity is resolved toward deny.

#!/usr/bin/env bash
input=$(cat) # the hook JSON on stdin
tool=$(printf %s "$input" | jq -r .tool_name)
cmd=$(printf %s "$input" | jq -r ".tool_input.command // (.tool_input|tostring)")
if pidge approve "Allow $tool?" --body "$cmd" --timeout 300 >/dev/null 2>&1; then
exit 0 # human approved (Face ID) → let the tool run
else
echo "Blocked: no human approval for $tool" >&2
exit 2 # exit 2 = PreToolUse BLOCK; fail-closed on deny/timeout/error
fi

(pidge approve --help carries this example too.)

3. The human-imposed knob — “Require approval · Face ID”

Section titled “3. The human-imposed knob — “Require approval · Face ID””

In the app, the human can turn on Require approval · Face ID for any profile (off by default everywhere). When it’s on for, say, important, every ordinary send on that profile becomes an Approve-with-Face-ID decision — even a plain send with no buttons. The server injects a single approve action; the human’s tap reaches the agent as chosen_action.action_id: "approve" via poll, webhook or listen --all.

The agent didn’t ask for this — the human imposed it. If your sends start reading back actions: ["approve"], requires_action: true, that’s what happened: read the echo, don’t fight it.

Any custom action marked destructive, confirm or biometric — and every custom action — renders only on the tap-through detail screen behind a confirm/Face ID gate, never as a lock-screen banner button. A stray banner tap can’t fire a risky action. See Sending → Buttons.