CC for Biz
2026/10/12Claude Code
AI活用

Claude Code Stopフックで貼ったスクショを自動保存

Claude Code Stopフックで貼ったスクショを自動保存

「説明のために Claude Code に貼ったスクショ、あとで記事に使いたいのにどこにも残っていない」——AI との会話に画面を貼る回数が増えるほど、こうした小さな損失が積み重なっていきます。

結論から言うと、Claude Code の Stop フック(AI が1回答え終わるたびに自動で走る後処理)で会話の記録ファイルを読み、人が貼った画像だけを日付付きのフォルダに保存して索引に1行足せば、スクショの保存作業はゼロになり、素材探しは文字の検索に変わります。設定は settings.json の数行と、90行ほどのスクリプト1本です。

株式会社Fyveでは、この仕組みを2026年9月25日から自社の作業環境で毎日動かしています。本記事では、私が実際に組んだ実装をもとに、Stop フックの公式仕様の確認、最小スクリプトの骨組み、「本業を絶対に止めない」ための設計判断、機密が写った画像への備えまでを順に解説します。

貼ったスクショは、会話が終わると行方不明になる

Claude Code を業務で使っていると、画面を貼って質問する場面が頻繁にあります。エラーが出た画面、崩れたレイアウト、管理画面の設定値、参考にしたいデザイン。言葉で説明するより1枚貼ったほうが速いからです。

問題は、その画像が「質問のための使い捨て」として扱われることです。貼った瞬間に役目を終え、多くの場合はデスクトップのスクショ置き場に紛れるか、クリップボードから直接貼って元ファイルすら残りません。

私の場合、これが困るのは記事や資料を作るときでした。「先週あのエラーを貼ったはず」「あのとき比べた2つの画面を図に使いたい」と思っても、見つからない。会話の記録をさかのぼって探すのは現実的ではありません。

そこで考え方を変えました。保存する手間を人に求めるのをやめ、貼った時点で自動的に残す。そして探すときは「いつ・どの会話で・なんと言って貼ったか」を文字で検索する。この2点を満たすのが、今回の Stop フックの仕組みです。

Stop フックとは何か(公式ドキュメントの定義)

Claude Code のフックは、決まったタイミングで任意のシェルコマンドを自動実行する仕組みです。LLM に「やってね」と頼むのではなく、確実に毎回走るのが特徴です。フック全般の入門は次の記事で解説しています。

Claude Code Hooks活用|品質チェックを自動化する方法
Claude CodeClaude Code Hooks活用|品質チェックを自動化する方法

このうち Stop は、公式の Hooks リファレンス(2026年10月13日に確認)で次のように定義されています。

  • メインの Claude Code エージェントが応答を終えたときに走る
  • ユーザーが中断(Esc 等)した場合は走らない
  • API エラーで終わったターンでは、Stop ではなく StopFailure が走る
  • サブエージェントの終了は別のイベント(SubagentStop)

フックには標準入力で JSON が渡されます。全イベント共通の項目に session_id・transcript_path(会話の記録ファイルのパス)・cwd・hook_event_name があり、Stop にはさらに stop_hook_active や last_assistant_message(最後の応答の本文)が加わります。

今回使うのは transcript_path です。Claude Code は会話を JSONL 形式(1行1件の JSON)のファイルに書き残しており、ユーザーが貼った画像もその中に入っています。Stop フックでこのファイルを読めば、そのターンまでに貼られた画像を取り出せます。

もう一つ押さえておきたいのが終了コードの意味です。公式ドキュメントによると、Stop フックが終了コード2で終わると「Claude が止まるのを防ぎ、会話を続けさせる」動きになります。つまり Stop フックは、作り方を誤ると Claude を延々と働かせ続ける力を持っています。保存係のような補助的なフックでは、この力を絶対に使わないよう設計する必要があります(後述)。

この記事を読んでいるあなたへ無料プレゼントClaude Codeを「素のまま」使うな設定で差がつく——CLAUDE.md・権限・スキルの実物を公開(全24ページ)
そのまま書き換えて使えるCLAUDE.mdの型お金と送信をAIに触らせない「3段階の柵」1回教えたら何度でも動く、手順書のコピペ雛形
中身のプレビューを見る ↓登録すると、過去の特典もまとめて受け取れます(PDF 17点・合計440ページ + すぐ使えるzip素材 3点)

仕組みの全体像:5つの段階

私が組んだ仕組みは、Stop フックが走るたびに次の5段階を処理します。

  1. 続きから読む:会話の記録ファイルを、前回読み終えた位置から読む。毎ターン全部を読み直さない
  2. 人が貼った画像だけ拾う:AI がツールで撮った画面や、サブエージェント側の画像は除外する
  3. 指紋で重複を弾く:画像データの sha256(画像の指紋)を計算し、保存済みと同じなら飛ばす
  4. 名前を付けて保存:assets/screenshots/YYYY/MM/ に、日時が分かるファイル名で書き出す
  5. 索引に1行足す:index.jsonl に「いつ・なんと言って貼ったか・直前に AI が何を言っていたか」を残す
Stopフックで貼ったスクショを残す仕組みの5段階(続きから読む・人の画像だけ拾う・重複を弾く・保存・索引)

ポイントは、保存先と索引を分けていることです。画像そのものはフォルダに日付順で並び、検索は索引のテキストに対して行います。画像には文字情報がないので、「貼ったときの自分の発言」と「直前の AI の発言」を一緒に残しておくことが、あとで探せるかどうかを決めます。

設定:settings.json に Stop フックを登録する

プロジェクトの .claude/settings.json に次のように登録します。私の環境で実際に使っている設定を、パスだけ一般化したものです。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "sh -c 'H=\"$CLAUDE_PROJECT_DIR/.claude/hooks/archive-screenshots.py\"; [ -f \"$H\" ] || { cat >/dev/null; exit 0; }; exec python3 \"$H\"'",
            "timeout": 60,
            "async": true
          }
        ]
      }
    ]
  }
}

この短い設定には、2つの判断が入っています。

判断1:async: true で「待たせない」

"async": true を付けると、フックはバックグラウンドで走り、Claude Code はその終了を待ちません。公式ドキュメントでも、時間のかかる処理向けの設定として案内されています。

スクショの保存は、そのターンの応答とは何の関係もない後処理です。画像が何枚もある長い会話を初めて読み込むときは相応に時間がかかるので、応答の後にユーザーを待たせる理由がありません。また async のフックは Claude の動作を止めたり制御したりできない、と公式に明記されています。保存係に余計な権限を持たせない、という意味でも都合が良い設定です。

判断2:ファイルが無ければ何もせず抜ける

コマンドの中身は「スクリプトのファイルがあれば実行し、無ければ標準入力を読み捨てて exit 0」です。私の環境ではこの設定ファイルを複数のマシンで共有しており、画像フォルダもスクリプトも置いていないマシンがあります。そこでフックが「ファイルが見つからない」エラーを出し続けないよう、入口で確かめています。

フックが黙って飛ばされる条件や、失敗時の扱いについては次の記事で詳しく整理しています。

Claude Codeのフックが効かない|黙って飛ばされる2条件
Claude CodeClaude Codeのフックが効かない|黙って飛ばされる2条件

スクリプトの骨組み(最小版・約90行)

実際に動かしているスクリプトは、セッション名やファイル名の整形、過去分の一括取り込みなどを含めて189行あります。ここでは仕組みの核だけを残した最小版を示します。この最小版は、ダミーの会話記録で「人が貼った画像は1回だけ保存される」「人以外の画像は拾わない」「2回目の実行では何も増えない」「入力が壊れていても終了コードは0」の4点を確認したものです。

#!/usr/bin/env python3
"""会話に人が貼った画像を assets/screenshots/ に保存し、index.jsonl に索引を足す(Stop フック用)"""
import base64, hashlib, json, os, sys
from datetime import datetime, timedelta, timezone

ROOT = os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", "."), "assets", "screenshots")
INDEX = os.path.join(ROOT, "index.jsonl")
STATE = os.path.expanduser("~/.cache/screenshot-archive/state.json")
JST = timezone(timedelta(hours=9))
EXT = {"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp"}


def load_json(path, default):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return default


def known_hashes():
    hashes = set()
    if os.path.exists(INDEX):
        with open(INDEX, encoding="utf-8") as f:
            for line in f:
                try:
                    hashes.add(json.loads(line)["sha256"])
                except Exception:
                    pass
    return hashes


def main():
    payload = json.load(sys.stdin)
    path = payload.get("transcript_path")
    if not path or not os.path.exists(path):
        return
    state = load_json(STATE, {})
    offset = state.get(path, 0)
    if os.path.getsize(path) < offset:  # 記録が短くなったら頭から読む
        offset = 0
    seen, rows, last_ai = known_hashes(), [], ""
    with open(path, "rb") as f:
        f.seek(offset)
        for raw in f:
            if not raw.endswith(b"\n"):  # 書きかけの行は次回に回す
                break
            offset += len(raw)
            try:
                o = json.loads(raw)
            except ValueError:
                continue
            content = (o.get("message") or {}).get("content")
            if not isinstance(content, list) or o.get("isSidechain"):
                continue
            text = " ".join(b.get("text", "") for b in content if b.get("type") == "text").strip()
            if o.get("type") == "assistant":
                last_ai = text[-300:] or last_ai
                continue
            if o.get("type") != "user" or (o.get("origin") or {}).get("kind") != "human":
                continue
            ts = datetime.fromisoformat(o["timestamp"].replace("Z", "+00:00")).astimezone(JST)
            for b in content:
                src = b.get("source") or {}
                if b.get("type") != "image" or src.get("type") != "base64":
                    continue
                h = hashlib.sha256(src["data"].encode()).hexdigest()
                if h in seen:  # 同じ画像は二度保存しない
                    continue
                seen.add(h)
                rel = f"{ts:%Y/%m}/{ts:%Y-%m-%d_%H%M}_{h[:8]}.{EXT.get(src.get('media_type'), 'png')}"
                os.makedirs(os.path.dirname(os.path.join(ROOT, rel)), exist_ok=True)
                with open(os.path.join(ROOT, rel), "wb") as w:
                    w.write(base64.b64decode(src["data"]))
                rows.append({"file": rel, "shared_at": f"{ts:%Y-%m-%d %H:%M}", "user_text": text[:600],
                             "context_before": last_ai, "sha256": h})
    if rows:
        with open(INDEX, "a", encoding="utf-8") as f:
            for r in rows:
                f.write(json.dumps(r, ensure_ascii=False) + "\n")
    state[path] = offset
    os.makedirs(os.path.dirname(STATE), exist_ok=True)
    with open(STATE, "w", encoding="utf-8") as f:
        json.dump(state, f)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        pass  # 何が起きてもセッションは止めない
    sys.exit(0)

以下、5つの段階ごとに、なぜこう書いたのかを説明します。

続きから読む:前回の位置を覚えておく

Stop フックは毎ターン走ります。会話が長くなると記録ファイルは数MB単位に膨らみ、画像を貼ればその分さらに大きくなります。毎回頭から読むのは無駄です。そこで、記録ファイルごとに「どこまで読んだか」(バイト数)を ~/.cache/ の下に保存し、次回はそこから読み始めます。

注意点が2つあります。1つ目は、行の途中で読み終えないことです。公式ドキュメントには、記録ファイルは非同期に書き込まれ、メモリ上の会話より遅れることがあると書かれています。そのため、改行で終わっていない最後の行は「書きかけ」とみなして読まず、次のターンに回します。

2つ目は、記録ファイルが前回より短くなった場合です。会話の圧縮などで記録が書き直されることを想定し、そのときは頭から読み直します。同じ画像を二度保存してしまう心配は、次の「指紋」で防いでいます。

人が貼った画像だけ拾う

会話の記録には、人が貼った画像以外にも画像が含まれます。たとえば Claude がブラウザ操作のツールで撮った画面や、サブエージェントの中でやりとりされた画像です。これらまで保存すると、素材フォルダがノイズで埋まります。

手元の記録を調べたところ、人が入力したメッセージには "origin": {"kind": "human"} という項目が付いており、サブエージェント側の行には isSidechain が付いていました。そこで「種類が user」「origin.kind が human」「isSidechain ではない」の3条件を満たす行の画像だけを拾っています。

ただし、これは手元の記録ではこうなっていた、という観察です。記録ファイルの中身の形式は、公式ドキュメントで保証された仕様ではありません。Claude Code の更新で項目名が変わる可能性があるので、「ある日から保存されなくなった」ときは、まずここを疑ってください。

指紋で重複を弾く

同じ画像を何度も貼ることはよくあります。修正前の画面を説明のために再度貼る、別の会話に同じ参考画像を持ち込む、といった場面です。画像データから sha256 を計算し、索引に同じ値があれば保存しません。ファイル名や貼った日時が違っても、中身が同じなら1枚として扱われます。

名前を付けて保存し、索引に1行足す

最小版のファイル名は「日時+指紋の先頭8文字」ですが、実際に動かしている版では「日時+会話のタイトル+貼ったときの発言の先頭」をファイル名にしています。Finder でフォルダを開いたときに、ファイル名だけでおおよその中身が分かるようにするためです。

索引の1行には、ファイルの場所、貼った日時、貼ったときの発言、直前の AI の発言、指紋を JSON で残します。実際の版ではこれに会話のタイトルや作業フォルダも加えています。

「止めない」設計:補助の仕事は本業を絶対に止めない

この仕組みで最も大事にしているのは、何が起きても終了コード0で終わることです。最小版でも、処理全体を例外処理で包み、最後に必ず exit 0 しています。

理由は先ほど触れた Stop フックの性質にあります。Stop フックは終了コード2で Claude の停止を妨げることができます。またそれ以外の失敗でも、会話の中にフックのエラー表示が出ます。スクショの保存に失敗したくらいで、本来の作業が止まったり画面がエラー表示で埋まったりするのは、明らかに割に合いません。

一方で、フックには「失敗したら止めるべきもの」もあります。たとえば機密情報の書き込みを防ぐ検査のフックは、壊れたまま素通りされるほうが危険です。Claude Code にはフックが失敗したときに処理を止める onFailure という設定があり、どちらの側に置くかの判断を次の記事で解説しています。

Claude Code onFailureとは|フック失敗で停止
Claude CodeClaude Code onFailureとは|フック失敗で停止
フックが壊れたときに止めるべき検査側と、止めてはいけない補助側の比較

私の判断の基準は単純で、「そのフックが壊れたとき、作業を続けるのと止めるのと、どちらの損が大きいか」です。検査のフックは止めるべき側、記録や通知のような補助のフックは止めてはいけない側です。スクショの保存は典型的な後者です。

止めない代わりに、失敗を黙って捨てない工夫も入れています。実際に動かしている版では、記録ファイル単位で例外を捕まえて ~/.cache/ の下のエラーログに1行書き、ほかの記録ファイルの処理は続けます。保存されていないと気づいたときに、ログを見れば理由が分かる状態にしておくためです。

公式ドキュメントと照らして分かった注意点

この記事を書くにあたって、改めて公式の Hooks リファレンスを読み直し、自分の設定と照合しました。運用していて気づかなかった点も含めて、注意点を4つ挙げます。

1. async のフックには timeout が効かない

私の設定には "timeout": 60 を書いていますが、公式ドキュメントによると、async で走っているフックには timeout が適用されません。60秒で打ち切られると思い込んでいましたが、実際には打ち切られない設定でした。保存処理は数秒で終わるので実害は出ていませんが、async のフックで時間の上限を設けたい場合は、スクリプトの中で自分で時間を区切る必要があります。

2. claude -p(非対話)では途中で打ち切られることがある

公式ドキュメントには、-p フラグを付けた非対話モードでは、終了時にまだ動いている async のフックを打ち切る、とあります。スクショを貼るのは対話セッションなので通常は問題になりませんが、自動実行の仕組みと設定ファイルを共有している場合、そちらでは保存が完了しないことがある点は覚えておくとよいでしょう。私の環境では、自動実行するマシンには画像フォルダ自体を置いていないので、入口の確認で何もせずに抜けます。

3. 中断したターンでは走らない(が、取りこぼさない)

Stop フックはユーザーが中断したときには走りません。画像を貼った直後に Esc で止めた場合、そのターンでは保存されません。ただし「続きから読む」設計にしているので、次に正常に終わったターンで、前回の位置から読み直すときに拾われます。毎回すべてを処理し切る作りではなく、「前回の続きから」にしておくと、こうした抜けが自然に回収されます。

4. 同じイベントのフックは並列に走る

公式のガイドによると、1つのイベントに複数のフックが登録されている場合、Claude Code はそれらを並列に実行します。私の環境では Stop に別の検査フックも登録していますが、スクショ保存は他のフックの結果に依存しないので問題ありません。もし「前のフックの結果を使う」ような組み方をしたいなら、1つのスクリプトにまとめる必要があります。

使い方:素材を探すときは索引を検索する

保存した画像を探すときは、フォルダを眺めるのではなく、索引を文字で検索します。jq(JSON を扱うコマンド)を使う例です。

# 貼ったときの発言に「エラー」を含むものを、日時とファイル名で一覧
jq -r 'select(.user_text | test("エラー")) | "\(.shared_at)  \(.file)"' assets/screenshots/index.jsonl

# 10月に貼ったものだけ
jq -r 'select(.shared_at | startswith("2026-10")) | .file' assets/screenshots/index.jsonl

私の場合は、記事や資料の素材を集めるときにまず索引を検索し、候補を数枚に絞ってから画像を開いて選びます。「直前の AI の発言」も残しているので、自分の発言が「これ」「この画面」のように短くても、文脈から探し当てられることが多いです。

また、仕組みを入れる前の会話に貼った画像も、記録ファイルが残っていれば取り込み直せます。実際に動かしている版には、すべての会話の記録ファイルを走査して過去分をまとめて保存するオプションを付けています。導入初日にこれを1回走らせれば、それ以前の素材も検索対象に入ります。

機密が写ったスクショへの備え

この仕組みは「人が貼った画像を全部」保存します。つまり、お客様の画面、管理画面の設定値、決済画面、個人名が写った画面も区別なく溜まります。便利さの裏返しとして、ここは運用で必ず手当てしてください。

  • 公開前に必ず目で確認する:索引から選んだ画像を記事や資料に使う前に、写っているものを1枚ずつ確認する。私たちは、お客様の画面は原則として公開物に使わないと決めています
  • 保存先を人と共有するフォルダにしない:チームの共有ドライブなどに置くと、本来見せる予定のない画面まで共有されてしまう
  • Git で管理しない:画像フォルダはリポジトリの管理対象から外す。私の環境では assets/ 全体を .gitignore で除外しています

「全部残す」と「何でも公開してよい」は別の話です。残す仕組みを自動にしたぶん、出すときの確認は人の手で行う、という役割分担にしています。

よくある質問

Q. 画像を貼るたびに動作が遅くなりませんか?

async: true にしているため、Claude Code はフックの終了を待ちません。体感で遅くなったことはありません。また「続きから読む」設計なので、会話が長くなっても1回の処理量はそのターンの増分だけです。

Q. Claude Code の更新で動かなくなることはありますか?

あり得ます。Stop フックの仕組みと transcript_path は公式に案内されていますが、記録ファイルの中の項目名(origin.kind など)は保証された仕様ではありません。止めない設計なので作業には影響しませんが、黙って保存されなくなる可能性があります。月に一度くらい、索引の最終行の日付が最近かどうかを見ておくと安心です。

Q. Claude Code 以外の画像(スクショアプリで撮ったもの)も一緒に管理できますか?

この仕組みが拾うのは「Claude Code の会話に貼った画像」だけです。ただ、私の実感では、あとで使いたくなる画像の多くは、一度は AI に貼って説明した画像でした。貼ったという事実そのものが「何かを説明する必要があった画面」の印になっている、と考えています。

Q. 最後の応答の本文を使いたい場合はどうすればよいですか?

公式ドキュメントでは、そのターンの最後の応答を使うフックは、記録ファイルではなく Stop の入力に含まれる last_assistant_message を使うよう案内されています。記録ファイルには Stop の時点で最後の応答が含まれていないことがあるためです。今回の仕組みは「人が貼った画像」が目的なので記録ファイルを読んでいますが、通知や読み上げのような用途なら last_assistant_message のほうが確実です。

まとめ

会話に貼ったスクショを自動で残す仕組みは、Stop フック1つと索引ファイル1つで作れます。押さえるべき点は次のとおりです。

  • Stop フックは応答が終わるたびに走り、transcript_path で会話の記録ファイルを受け取れる
  • 記録は前回の続きから読み、書きかけの行は次回に回す
  • 拾うのは人が貼った画像だけ。判定に使う項目は公式の仕様ではないので、壊れたらまずここを疑う
  • 重複は画像の指紋(sha256)で弾く
  • 補助のフックは何が起きても exit 0。止めるべき検査のフックとは役割を分ける
  • async のフックには timeout が効かない。上限が必要ならスクリプト側で区切る
  • 機密が写った画像も溜まる前提で、公開前の目視確認を運用に組み込む

保存する手間をゼロにすると、「あの画面どこだっけ」という時間が検索1回に変わります。株式会社Fyveでは、こうした小さな自動化を積み重ねて、Claude Code を日々の業務の土台として使っています。

この記事を読んでいるあなたへ無料プレゼント

Claude Codeを「素のまま」使うな

設定で差がつく——CLAUDE.md・権限・スキルの実物を公開(全24ページ)

素のClaude Codeは"優秀な新入社員"。仕事を教えるほど、自分専用になります。覚えさせる4点セット——会社の説明書(CLAUDE.md)・権限の柵・手順書(スキル)・フォルダの地図——を、1人会社の実運用からコピペで使える型つきで公開します。

  • そのまま書き換えて使えるCLAUDE.mdの型
  • お金と送信をAIに触らせない「3段階の柵」
  • 1回教えたら何度でも動く、手順書のコピペ雛形
  • AIが迷子にならないフォルダ構造の3原則

メールアドレス登録で他にも様々な資料を閲覧できます

「Sonnet 5.5」を、実務で比べてみた。Codexを使い始める前に、「絶対に」見直してほしい5つのこと「Opus 5.5」を、実務で比べてみた。Jevで得する仕事、損する仕事Astra と Fable 5.1、ベンチマークでは分からない「使い分け」。Fable 5.1、賢さは「どこ」に出たか。Workにしかできない仕事は、2つだけその太字、本物ですかClaudeのこの5つの設定、今すぐ見直した方がいい+8

PDF 17点・合計440ページ + すぐ使えるzip素材 3点

どれも登録後の受け取りページから、まとめてダウンロードできます。

毎週配信の無料ニュースレター「AIネイティブ超研究」の購読特典です。メール登録後すぐ、受け取りページのご案内が届きます。そこにはこの資料に加えて、過去の特典もすべてまとめて置いてあります。あわせて、AI活用に関するお知らせやお役に立てそうなご案内をお送りすることがあります。解除はいつでも1クリック。

← 記事一覧に戻る

御社の業務に合わせたClaude Code導入支援

「AIツールを導入したが、現場で使われない」を終わらせる。
業務課題のヒアリングから設計、ハンズオン実践、運用定着まで一貫して支援します。

無料AI活用診断を受ける料金とサービス一覧を見る →
© 2025 Fyve Inc. All rights reserved.