Key Takeaways

  • Claude Code hooks can call an MCP server directly. The mcp_tool handler type hits an already-connected server over the existing connection, so a hook can query Stash with no shell script in between.
  • A SessionStart hook with matcher startup|resume|compact hands the agent your last three captures at every cold start, every resume, and every recovery from compaction.
  • A UserPromptSubmit hook passes ${user_prompt} into Stash search, so the question you typed searches your own screen history before the model answers it.
  • Only three events take plain hook output into context. SessionStart, UserPromptSubmit, and UserPromptExpansion. From PostToolUse you print JSON with hookSpecificOutput.additionalContext or the model never sees a word of it.
  • Write the file filter as "if": "Edit(**/*.tsx)". The ** prefix matches at any depth in every rule shape, and Edit() covers the Write tool too, because Claude Code consults Edit(path) and Read(path) rules and nothing else.
  • A list_recent summary is about 100 tokens per capture. A screenshot pasted as a PNG runs 1,500 to 4,000 tokens and the model still has to guess which app it is looking at.
  • Never hook get_capture or get_bundle. Those are the full dossiers. The agent should pull them deliberately, by ID, once it knows which one it wants.
  • Set timeout yourself. The default for an mcp_tool hook is 600 seconds. Five is plenty for a local socket.

The Whole Config

Drop this into ~/.claude/settings.json and Claude Code reads your screen captures without being asked.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume|compact",
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "stash",
            "tool": "list_recent",
            "input": { "n": 3 },
            "timeout": 5,
            "statusMessage": "Loading recent captures"
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "stash",
            "tool": "search",
            "input": { "query": "${user_prompt}" },
            "timeout": 5,
            "statusMessage": "Searching captures"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "if": "Edit(**/*.tsx)",
            "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"A view file just changed. Call the stash list_recent tool with n set to 1 and check whether the newest capture is one you have not read yet.\"}}'",
            "timeout": 5,
            "statusMessage": "Flagging newest capture"
          },
          {
            "type": "command",
            "if": "Edit(**/*.swift)",
            "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"A view file just changed. Call the stash list_recent tool with n set to 1 and check whether the newest capture is one you have not read yet.\"}}'",
            "timeout": 5,
            "statusMessage": "Flagging newest capture"
          }
        ]
      }
    ]
  }
}

The server value has to match the name you registered. If you installed with the one-command script or ran the CLI yourself, that name is stash:

claude mcp add stash -s user -- /Applications/Stash.app/Contents/Helpers/stash-mcp

Use the absolute path. GUI clients launched from the Dock do not inherit your shell PATH, and a bare stash-mcp will resolve fine in your terminal and fail everywhere else.

Two of those hooks call Stash directly. The third prints a line of JSON instead, and the reason is the single most useful thing in the hooks reference.


Only Three Events Take Plain Text

Three events put plain hook output straight into the model's context: SessionStart, UserPromptSubmit, and UserPromptExpansion. The hooks reference states it flat:

"For most events, stdout is written to the debug log but not shown in the transcript. The exceptions are UserPromptSubmit, UserPromptExpansion, and SessionStart, where stdout is added as context that Claude can see and act on."

A Stash list_recent response is plain text. At SessionStart that plain text lands in context. At PostToolUse it lands in a debug log you are never going to open. Same tool, same server, same 300 tokens, and one of the two does nothing at all.

Every other event needs an envelope. Print this and the string shows up next to the tool result, wrapped in a system reminder, where the model reads it:

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "your text here"
  }
}
Event Plain text reaches the model? Use
SessionStart Yes mcp_tool, straight to Stash
UserPromptSubmit Yes mcp_tool, straight to Stash
UserPromptExpansion Yes Same, if you use it
PostToolUse No, debug log only additionalContext JSON
Everything else No, debug log only additionalContext JSON

This is where most published hook configs are quietly broken. The hook runs. It exits 0. The spinner message appears. Nothing reaches the model.


Timing, Not Volume

The value of a hook is that it fires at a moment you chose. That is a different thing from giving the model more to read.

Opus 5, which shipped on July 24, 2026, carries a 1M-token context window. It does not save you. Chroma's Context Rot report tested 18 models and put it plainly: "model performance degrades as input length increases, often in surprising and non-uniform ways." Kelly Hong, Anton Troynikov and Jeff Huber published that on July 14, 2025, and nothing since has made it untrue.

So the goal is not to hand the agent everything you captured today. It is to hand it 300 tokens at the second it is about to act. Three list_recent summaries at session start. One matching capture ID when your prompt mentions Safari. One sentence right after it rewrites a SwiftUI view.

The other thing the hook buys you is the sentence you stop typing. "Look at my last Stash capture" is nine words, twenty times a day, and every one of those is a turn where you had to remember that the context existed at all. Hooks are how you forget about it.


Hook 1: Start Every Session Warm

SessionStart is the highest-value hook in this set, and the matcher is why. Claude Code's hooks reference lists five matcher values for the event: startup, resume, clear, compact, and fork. The one people miss is compact, which fires after context compaction.

That matters more than it sounds. Compaction is aggressive by design. One analysis of Claude Code sessions in Towards Data Science measured auto-compaction squeezing 132,000 tokens of accumulated message state down to roughly 2,300, a 98% reduction. Your capture IDs are not what survives that.

With compact in the matcher, the agent gets them back the moment compaction finishes. It costs three summary lines. Each list_recent entry is an app name, window title, 8-character shortID, timestamp, and kind, around 100 tokens. Three hundred tokens to keep an agent oriented across a four-hour session is the cheapest trade in this file.


Hook 2: Every Prompt Searches Your Captures

Hook search to UserPromptSubmit and the text you typed becomes the query. The input object supports ${path} substitution from the event's own JSON payload, and UserPromptSubmit carries the typed text in a field called user_prompt. So "query": "${user_prompt}" is the whole trick.

Stash search runs substring matching across app name, window title, bookmark name, text content, and browser URL. Type "why is the login sheet clipped in Safari" and it comes back with the Safari captures you took while the sheet was clipped. You did not reference them. You just described the problem, which is what you were going to type anyway.

It returns matching IDs and snippets only, not full dossiers, so the cost stays near zero on a miss. Two things to know before you turn it on. It fires on every prompt, including "yes" and "keep going." And UserPromptSubmit hooks time out at 30 seconds by default rather than the usual 600, which is still 30 seconds too long for a Unix socket. Set it to 5.


Hook 3: Right After It Touches a View File

PostToolUse gets a command hook that prints one line of JSON, and that line tells the agent to go fetch the capture itself. The agent already holds an open connection to the Stash server, so the hook does not need to make the call. It needs to say the word.

That is the whole design, and it is forced by the event. PostToolUse fires after a tool call succeeds, and its plain output goes to the debug log. An mcp_tool handler here would query Stash correctly, return your newest capture correctly, and drop it on the floor. So the handler echoes an additionalContext envelope instead, and the model reads a sentence next to the tool result.

Now the file filter, which is where the second set of mistakes lives. Three rules, all from the permissions reference, because if takes permission-rule syntax rather than a matcher pattern:

The loop it creates is the one that matters for UI work:

agent edits ContentView.swift
   |
   v
you look at the build, hit Ctrl+Cmd+S on the broken render
   |
   v
next edit lands -> PostToolUse -> echo additionalContext JSON
   |
   v
"a view file just changed, check stash list_recent"
   |
   v
agent calls list_recent(1), sees a shortID it has not read
   |
   v
agent calls get_capture on that ID

That last step is the point. The hook does not deliver the screenshot. It delivers the reason to go looking for one, for the price of one sentence, and lets the agent decide whether to spend tokens on the full dossier. That dossier is where the real grounding lives: bundle ID, window title, macOS version, the accessibility tree of every element in the window, cursor position, active file path, and your annotation shapes as geometry rather than baked pixels.

PostToolUse takes a second output field worth knowing about. hookSpecificOutput.updatedToolOutput replaces a tool's result before the model sees it, and as of v2.1.121 it works on every tool rather than MCP tools only. Do not reach for it here. It swaps the result out instead of adding to it, so using it on an Edit would hand the model your capture summary in place of the diff it just wrote. Redaction and noisy-output filtering are what that field is for.


What Not to Hook

get_capture and get_bundle stay out of your hooks file. Those are the heavy payloads. A full video bundle report from a five and a half minute session is about 22 KB, call it 6,000 tokens, before the agent has read a single frame. Wonderful when the agent chose it. Ruinous on every file write.

The split is worth stating flat, because it is the design principle behind the whole setup:

Tool Rough cost Hook it?
list_recent(n) ~100 tokens per capture Yes
search(query) IDs and snippets only Yes
render_plain(id) Small, but you rarely want it unprompted Rarely
get_capture(id) Full dossier, a11y tree included No, let the agent ask
get_bundle(id) ~6,000 tokens plus frames No, let the agent ask

Cheap tools go in the hook. Expensive tools stay behind a decision the model makes with its eyes open.


The Two Settings People Skip

Set the timeout. Default for command, http, and mcp_tool hooks is 600 seconds. A local Unix socket answers in milliseconds, so if it has not answered in five, something is wrong and you want the turn to move on.

Keep them context-only. Do not hand these hooks a decision of block. If Stash is not running, the hook has nothing to return and the session should carry on like a normal session. A capture-context hook that can wedge your agent is worse than no hook.

One more thing that is easy to miss in the docs: the agent handler type is still marked experimental. Everything above uses mcp_tool and command, which are not.

None of this touches the network. The Stash MCP server listens on a Unix domain socket at ~/Library/Application Support/Stash/mcp.sock, clipboard history and screenshots sit in local SQLite, and only clients you approve in Settings can connect. Copied text is scanned for secrets before it is persisted, so an sk- key or an AKIA prefix is replaced with [redacted] before it ever reaches disk, let alone a hook payload.


Doing This in Cursor

Cursor's hook layer exists but works differently. As of v3.11, released July 10, 2026, the events include beforeSubmitPrompt, afterFileEdit, beforeShellExecution, beforeMCPExecution, and afterMCPExecution. They run scripts. There is no mcp_tool handler, beforeSubmitPrompt returns only { "continue": boolean }, and afterFileEdit is notification-only, so context injection is not the mechanism it is in Claude Code.

On Cursor, connect the same Stash server through the MCP config and put one line in your rules file telling the agent to call list_recent when a question involves the UI. Less automatic. Same five tools, same socket, same data.


The Bottom Line

Three hooks, about fifty lines of JSON, and roughly 300 tokens per session. The agent starts warm, your prompts search your own screen history, and the moment it finishes editing a view file it gets told to go look for a newer capture. Two of the three call the Stash MCP server directly. The third prints an additionalContext envelope, because PostToolUse output reaches the model no other way, and a hook that silently writes to a debug log is the most expensive kind of broken. You never type "look at my last screenshot" again, and the agent stops reasoning from pixels it had to guess at.

Get the captures your agent can query

Local MCP server, five tools, context banner and accessibility tree on every screenshot.

Download Stash for free

Frequently Asked Questions

Can a Claude Code hook call an MCP server directly?

Yes. The hooks reference documents a handler type of mcp_tool, which calls a tool on an already-connected MCP server over the existing connection. You give it a server name, a tool name, and an input object. No shell script, no subprocess, no stdio bridge of your own.

Which hook event should I wire up first?

SessionStart, with the matcher startup|resume|compact. It fires once per cold start, once per resume, and once after compaction, so it costs almost nothing. It also fixes the most annoying failure mode: an agent that has forgotten every capture ID it was handed 40 minutes ago.

Why does my PostToolUse hook output never reach the model?

Because plain stdout from PostToolUse goes to the debug log, not the transcript. The hooks reference names three exceptions where plain output is added as context: UserPromptSubmit, UserPromptExpansion, and SessionStart. From every other event you have to print JSON with hookSpecificOutput.additionalContext, or the hook runs, succeeds, and changes nothing.

How many tokens does hooking list_recent actually add?

About 100 tokens per capture summary, so n: 3 costs roughly 300 tokens per session start. A screenshot pasted into the chat as a PNG runs 1,500 to 4,000 tokens, and the model still has to read the pixels to work out which app it is looking at. The accessibility tree for a typical window is 1 to 5 KB of structured text by comparison.

Does the Stash MCP server send my captures anywhere?

No. It listens on a Unix domain socket at ~/Library/Application Support/Stash/mcp.sock. No network hop, no cloud relay. Clipboard history and screenshots live in local SQLite on your Mac, and only MCP clients you approve in Settings can connect.

What happens if Stash is not running when the hook fires?

The hook has nothing to return and the turn carries on. Keep these hooks context-only and never give them a decision of block, so a stopped app degrades into a normal session instead of a stuck one. Set an explicit timeout of about 5 seconds as well, because the default for an mcp_tool hook is 600.

Can I do the same thing in Cursor?

Partly. Cursor v3.11 has hooks including beforeSubmitPrompt, afterFileEdit, and beforeMCPExecution, but those handlers run scripts rather than MCP tools, and beforeSubmitPrompt returns only a continue boolean. Point Cursor at the same Stash MCP server and put the instruction in your rules file instead.

References