この記事はClaudeと一緒に書きました。
ベースを書いてもらったあと、人間が手直ししています。
こんにちは、ふくちです。
Xで「Claude Codeの承認をApple Watchからやってる」という投稿を見かけて、これは私もやりたい!と思い挑戦してみました。
少し調べてみると、おそらくカスタムのwatchOSアプリを開発しているようでした。ただ、それだとApple Developer Program(年額$99)への加入とXcodeでのアプリ開発が必要になります。さすがにこれだけのためにそこまでやるのはちょっと…ということで、既存のツールを組み合わせてなんとかできないか模索してみました。
最終的にPushcut + ntfy.shのハイブリッド方式で実現できたのですが、そこに至るまでに若干ハマったので、その過程を共有します。
tl;dr
- Claude CodeのPermissionRequest Hookを使うと、ツール実行の許可判定をスクリプトで制御できる
- ntfy.sh単体ではApple Watchにアクションボタンが表示されないため、Pushcut(iOSネイティブ通知を生成できるアプリ)を組み合わせる
- Pushcutで通知配信、ntfy.shで応答チャネルという役割分担にすることで、シンプルに構成できた
やりたいこと
Claude Codeがファイル編集やコマンド実行の許可を求めてきたとき、Macの前にいなくてもApple Watchの通知から承認したいというのがゴールです。
理想としてはDouble Tap(指を2回合わせるジェスチャー)で承認まで完結させたかったのですが、こちらは断念しました(後述します)。
Claude Code Hooksについて
Claude CodeにはHooksという仕組みがあり、特定のイベントのタイミングでシェルスクリプトを実行できます。
今回使うのはPermissionRequest Hookで、権限ダイアログが表示される直前に発火します。スクリプトの標準出力にJSONでallow / denyを返すと、ダイアログをスキップしてそのまま処理を続行 / 拒否できます。
Hookスクリプトはブロッキング実行なのでスクリプトが終了するまでClaude Codeは待ってくれます。その待ち時間に対するタイムアウトも設定できます。5分程度設定しておけば十分ではないでしょうか。
{
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": { "command": "ls /tmp" }
}
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}
behaviorには"allow" / "deny" / "ask"(通常のCLIプロンプトにフォールバック)が指定できます。
Phase 1: ntfy.sh + claude-remote-approverを試す
ntfy.shとは
ntfy.sh(「notify」と読む)はHTTPベースのpub-sub通知サービスです。
アカウント登録不要で、任意のトピック名に対してHTTP POSTするだけでスマホにプッシュ通知を送れます。
# Publish: 以下でメッセージを送信できる
curl -d "Hello" https://ntfy.sh/my-topic
# Subscription: ntfyアプリでmy-topicを購読しておくと、メッセージがプッシュ通知で届く
iOSアプリでトピックを購読しておけば、そのトピックへのPOSTがリアルタイムで通知されます。無料で使えて、セルフホストも可能です。
今回はこのntfy.shを「Mac ↔ スマホ間のメッセージングチャネル」として使います。
まずは既存のツールがないか調べたところ、このntfy.shとClaude Code Hooksを組み合わせたOSSがいくつか見つかりました。
その中で今回は一番手軽そうなclaude-remote-approverを試してみることにしました。
セットアップ手順
1.claude-remote-approverをインストール
pnpm install -g claude-remote-approver
2.setupを実行(Claude Codeのセッション外で)
claude-remote-approver setup
ランダムなトピック名が生成されて~/.claude/settings.jsonにHookが自動登録されます。
注意: Claude Codeのセッション内から実行するとstdinの競合でエラーになります。必ず別ターミナルで実行してください。
3.iPhoneにntfyアプリをインストール(App Storeで「ntfy」を検索)
4.ntfyアプリでトピックを購読する
- ntfyアプリの「+」ボタンをタップ
-
~/.claude-remote-approver.jsonを開いてtopic値をコピー - Topic Nameの欄に上記topic値(cra-xxx...)をペースト
- Subscribeをタップ
5.テスト通知で動作確認
claude-remote-approver test
動作確認
テスト通知を送るとiPhoneにもApple Watchにも届きました。Claude Codeのセッション内でコマンドを実行すると、ntfyアプリに通知が飛んで、アプリ内のAllow/Denyボタンをタップすれば承認が返されて処理が続行されます。
問題点: Apple WatchではAllow/Denyのボタンが出ない
ここで重要な問題が発覚しました。
ntfyのAllow/DenyボタンはiOSのネイティブ通知アクション(UNNotificationAction)ではなく、ntfyアプリ内のUIとして描画されています。
iPhoneではntfyアプリを開けばボタンが表示されるので、そこでAllow/Denyを選択すれば承認/否認できます。
しかしApple Watchでは通知は届いてもボタンが表示されません。すなわち承認/否認できません。
ntfy.shの公式ドキュメントでもアクションボタンは「Supported on: Android, Web」と記載されていて、iOSのネイティブ通知アクションには対応していませんでした。
上記より、ntfy.sh単体ではApple Watchからの承認は不可能ということがわかったので、別のアプローチを探しました。
Phase 2: Pushcut + ntfy.shハイブリッド方式
PushcutはiOSのネイティブ通知アクション(UNNotificationAction)を生成できるアプリです。これを使うことで
- Apple Watchの通知にアクションボタンが表示され、承認/否認できるようになる
- Background RequestとしてHTTPリクエストを送信できる
- API経由で通知を送れる(Proプラン、月額$2程度)
ということが可能になります。
ただしPushcutだけでは応答をMacに返す仕組みが複雑になる(ローカルサーバーや中間サーバーが必要)ので、Pushcut(通知配信)+ ntfy.sh(応答チャネル)のハイブリッド構成にしました。
処理の流れは以下のようになっています。
Claude Code (Mac)
│ PermissionRequest Hook発火
▼
Hookスクリプト
│ 1. Pushcut APIで通知送信(タイトル・テキストのみ動的)
│ 2. ntfy.shのresponseトピックをポーリング
▼
Pushcut
│ iPhoneとApple Watchにネイティブ通知
│ Allow/Denyボタン表示
▼
ユーザーがAllow/Denyをタップ
│ PushcutがBackground Requestでntfy.shにPOST
▼
Hookスクリプト(ポーリングで受信)
│ → Claude Codeにallow/denyを返却
PushcutがやるのはApple Watchに通知を出してボタンを押されたらntfy.shにPOSTするだけ。
ntfy.shがやるのはそのPOSTをMac側のHookスクリプトに届けるだけ。
それぞれの得意な部分だけを使っている感じです。
セットアップ手順
1.iPhoneにPushcutアプリをインストールしてアカウント作成
2.Pushcut Proに加入(月額$2程度。API経由での通知送信に必要)
3.API Keyを取得: Pushcutアプリ → Accountタブ → 「Add API Key」
4.通知テンプレートを作成: Pushcutアプリ → Notificationsタブ → 「+」で新規作成
- 名前は任意だが、ここでは
Claude Watchとする(この名前がAPIで使われます) - 「Add Action」から以下のAllow/Denyアクションを追加
| Label | URL | Background Request | Method | Content Type | Body |
|---|---|---|---|---|---|
| Allow | https://ntfy.sh/<トピック名>-response |
ON | POST | Plain | allow |
| Deny | https://ntfy.sh/<トピック名>-response |
ON | POST | Plain | deny |
5.設定ファイルを作成(~/.config/claude-watch/config.json)
{
"pushcut_api_key": "<上記3.で取得したAPI Key>",
"pushcut_notification": "Claude Watch",
"ntfy_topic": "<Phase 1で使ったntfyトピック値>",
"ntfy_server": "https://ntfy.sh",
"timeout": 300
}
6.Hookスクリプトを配置(~/.local/share/claude-watch/pushcut-hook.sh)
Pushcut APIで通知を送信し、ntfy.shをポーリングして応答を待つbashスクリプトです。スクリプト全体は本記事の末尾を参照してください。
7.Claude CodeのHookを登録(~/.claude/settings.json)
{
"hooks": {
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "~/.local/share/claude-watch/pushcut-hook.sh",
"timeout": 300
}
]
}
]
}
}
8.Apple Watchへの通知転送を有効にする: iPhoneのWatchアプリ → 通知 → Pushcutを有効にする
9.動作確認: Claude Codeで新しいセッションを開始し、コマンド実行等でPermissionRequest Hookを発火させてiPhone/Apple Watchに通知が届くか確認
Hookスクリプトの解説
スクリプト本体のポイントだけ抜粋すると、応答の受信はこんな感じです。
# 通知送信前にタイムスタンプを記録
START_TS=$(date +%s)
# Pushcut APIで通知送信(タイトルとテキストのみ、アクションはアプリ側で固定)
curl -s -X POST "https://api.pushcut.io/v1/notifications/${ENCODED_NOTIFICATION}" \
-H "API-Key: ${PUSHCUT_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"title\": \"Claude Code: ${TOOL_NAME}\", \"text\": \"${TOOL_INFO}\"}"
# ntfy.shを2秒ごとにポーリングして応答を待つ
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
sleep 2
ELAPSED=$(( $(date +%s) - START_TS ))
RESULT=$(curl -s "${NTFY_SERVER}/${RESPONSE_TOPIC}/json?poll=1&since=${START_TS}" | \
jq -r 'select(.event == "message") | .message' | \
grep -E '^(allow|deny)$' | tail -1) || true
if [ -n "$RESULT" ]; then
break
fi
done
タイムアウトした場合やエラー時はexit 0で終了し、通常のCLIプロンプトにフォールバックするようにしています。
ハマりポイント集
いくつかのトラブルシューティング集です。
1. Pushcut APIでアクションを動的に設定するとBackground Requestが消える
当初、APIのactions配列でURLを動的に上書きしようとしました:
{
"title": "Claude Code: Bash",
"text": "ls /tmp",
"actions": [
{ "name": "Allow", "url": "https://ntfy.sh/<dynamic-topic>" }
]
}
するとAllowを押したときに「executing the url returned status code 404」というエラーが出ました。
原因は、APIでactionsを指定すると、アプリで事前設定したBackground Request / POST / Bodyの設定がすべて上書きされることでした。結果としてPushcutはブラウザでURLを開こうとして404になっていたわけです。
解決策として、APIではタイトルとテキストのみ送信し、アクションの設定はアプリ側で固定する形にしました。
応答トピックも固定の<トピック名>-responseを使うようにしています。
2. ntfy.shのsince=nowは無効
ntfy.shのsubscribe APIでsince=nowを使ったらinvalid since parameterエラーが返ってきました。
sinceパラメータはいつ以降のメッセージを取得するかを指定するクエリパラメータです。
ドキュメントを確認すると、有効な値は以下の通りでした。
-
since=all(全キャッシュ) -
since=10m(期間形式) -
since=1645970742(Unixタイムスタンプ) -
since=latest(最新のみ)
since=nowはないんですね。通知送信時のUnixタイムスタンプをsinceに渡すことで解決しました。
Double Tapについて
当初の理想はApple WatchのDouble Tap(Series 9以降で使える、指を2回ピンチするジェスチャー)で承認を完結させることでした。
Double Tapはプライマリアクション(通知の最初のボタン)を実行するはずなのですが、実際に試すと通知の「閉じる」ボタンにフォーカスが当たってしまい、Allowが実行されませんでした。通知バナー表示中にDouble Tapしても同様です。
Pushcutが生成する通知カテゴリの設定に依存する部分があり、サードパーティアプリ経由では制御が難しいようです。カスタムwatchOSアプリを作れば.handGestureShortcut(.primaryAction)で対応できますが、そこまでやるかは悩ましいところです。
現状はApple Watchで通知を開いてAllowをタップする運用で、十分実用的に使えています。
ntfyの通知はOffにしておく
ある程度動作確認できたところで、ntfy側の通知はOffにしておくほうが良いと思います。
なぜならPushcutの通知とntfyの通知が二重に来てしまうため。
Pushcut側で承認/否認すればOKなので、ntfy側の通知はなくても問題ありません。
ただし開発中はどこまで連携できているかを確認するためにも、Onにしておくほうが良さそうです。
まとめ
| 方式 | iPhone | Apple Watchボタン | Double Tap |
|---|---|---|---|
| ntfy.sh単体 | アプリ内のみ | 非対応 | 非対応 |
| Pushcut + ntfy.sh | ネイティブ通知 | 対応 | 対応はしているが難しい |
| カスタムwatchOSアプリ | - | 対応 | 対応(恐らく) |
Macの前にいなくてもApple WatchからClaude Codeの承認ができるようになりました。一度タスクを依頼しておけば散歩中でも料理中でも、手首に通知が来て1タップで承認できるのはなかなか良い体験です。
Double Tapは実現できませんでしたが、もしwatchOSアプリの開発に手を出す機会があれば再挑戦してみたいと思います。
良い改善案などあればぜひ教えて下さい!
参考:スクリプト全文
#!/bin/bash
set -euo pipefail
# Claude Code PermissionRequest Hook: Pushcut + ntfy.shハイブリッド方式
# Pushcut: 通知配信(Apple Watchのネイティブアクションボタン対応)
# ntfy.sh: 応答チャネル(ポーリング方式で安定性を確保)
CONFIG_FILE="$HOME/.config/claude-watch/config.json"
# 設定ファイルの読み込み(存在しなければCLIフォールバック)
if [ ! -f "$CONFIG_FILE" ]; then
exit 0
fi
PUSHCUT_API_KEY=$(jq -r '.pushcut_api_key' "$CONFIG_FILE")
PUSHCUT_NOTIFICATION=$(jq -r '.pushcut_notification' "$CONFIG_FILE")
NTFY_TOPIC=$(jq -r '.ntfy_topic' "$CONFIG_FILE")
NTFY_SERVER=$(jq -r '.ntfy_server // "https://ntfy.sh"' "$CONFIG_FILE")
TIMEOUT=$(jq -r '.timeout // 120' "$CONFIG_FILE")
# 応答用トピック名(リクエスト用トピックに-responseを付けたもの)
RESPONSE_TOPIC="${NTFY_TOPIC}-response"
# Claude Codeからstdinで受け取るPermissionRequest JSONを読み取る
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "Unknown"')
# ツール種別に応じて通知に表示する情報を組み立てる(最大200文字)
case "$TOOL_NAME" in
Bash)
TOOL_INFO=$(echo "$INPUT" | jq -r '.tool_input.command // (.tool_input | tostring)' | head -c 200)
;;
Read|Write|Edit)
TOOL_INFO=$(echo "$INPUT" | jq -r '.tool_input.file_path // (.tool_input | tostring)' | head -c 200)
;;
*)
TOOL_INFO=$(echo "$INPUT" | jq -r '.tool_input | tostring' | head -c 200)
;;
esac
# 通知テンプレート名をURLエンコード(Pushcut APIのURLパスに含めるため)
ENCODED_NOTIFICATION=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${PUSHCUT_NOTIFICATION}'))")
# ntfy.shのsinceパラメータ用にタイムスタンプを記録(通知送信前に取る)
START_TS=$(date +%s)
# Pushcut APIで通知を送信(タイトルとテキストのみ動的、アクションはアプリ側で固定)
PUSHCUT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
"https://api.pushcut.io/v1/notifications/${ENCODED_NOTIFICATION}" \
-H "API-Key: ${PUSHCUT_API_KEY}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg title "Claude Code: ${TOOL_NAME}" \
--arg text "$TOOL_INFO" \
'{
title: $title,
text: $text
}')" 2>&1)
# HTTPステータスコードを確認(200/201/204以外はエラー → CLIフォールバック)
HTTP_CODE=$(echo "$PUSHCUT_RESPONSE" | tail -1)
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "204" ]; then
PUSHCUT_BODY=$(echo "$PUSHCUT_RESPONSE" | sed '$d')
echo "Pushcut APIエラー (HTTP $HTTP_CODE): $PUSHCUT_BODY" >&2
exit 0
fi
# ntfy.shを2秒ごとにポーリングしてユーザーの応答を待つ
# poll=1: キャッシュ済みメッセージを返して即接続を閉じる
# since: 通知送信後のメッセージだけを取得する
RESPONSE=""
ELAPSED=0
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
sleep 2
ELAPSED=$(( $(date +%s) - START_TS ))
# 通知送信後のメッセージを取得し、allow/denyに一致するものだけ採用
RESULT=$(curl -s "${NTFY_SERVER}/${RESPONSE_TOPIC}/json?poll=1&since=${START_TS}" 2>/dev/null | \
jq -r 'select(.event == "message") | .message // empty' 2>/dev/null | \
grep -E '^(allow|deny)$' | tail -1) || true
if [ -n "$RESULT" ]; then
RESPONSE="$RESULT"
break
fi
done
# Claude Codeに結果をJSON形式で返す
case "$RESPONSE" in
allow)
jq -n '{
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: { behavior: "allow" }
}
}'
;;
deny)
jq -n '{
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "deny",
message: "Apple Watch / Pushcut経由で拒否されました"
}
}
}'
;;
*)
# タイムアウトまたは不明な応答 → CLIの通常プロンプトにフォールバック
exit 0
;;
esac