<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Agents on Welcome to Christophe Nasarre's Blog</title><link>https://chrisnas.github.io/tags/agents/</link><description>Recent content in Agents on Welcome to Christophe Nasarre's Blog</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 23 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://chrisnas.github.io/tags/agents/index.xml" rel="self" type="application/rss+xml"/><item><title>Spying on Cursor: agent hooks, payloads and a simple observer</title><link>https://chrisnas.github.io/posts/2026-08-23_spying-on-cursor-hooks/</link><pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate><guid>https://chrisnas.github.io/posts/2026-08-23_spying-on-cursor-hooks/</guid><description>How I used Cursor agent hooks to observe prompts, thoughts, tool calls, MCP executions, file edits and subagents without changing the agent&amp;#39;s behavior.</description><content:encoded><![CDATA[<p>In <a href="/posts/2026-06-08_dotnet-cli-tools-in-the-ai-fury/">my previous post about MCP servers and skills</a>, I explained how to help an AI coding agent to use diagnostic tools the right way during memory and threading troubleshooting workflows. Once these tools were available in Cursor, Claude Code and Copilot, another question quickly followed: <strong>are the agents really using them the way I was expecting?</strong></p>
<p>More precisely:</p>
<ul>
<li>Did the agent load my skill when expected?</li>
<li>Which MCP tool did it call, with which arguments and result?</li>
<li>Did a subagent perform the work?</li>
<li>How much time and context did the whole operation consume?</li>
</ul>
<p>Between my prompt and the model sits an <strong>agent harness</strong>; Cursor IDE or Cursor CLI in my case. Cursor prepares the context, asks for permissions, starts subagents, compacts the conversation and renders the answer received from the model black box. Since I wanted to better understand what was happening, I asked&hellip; Cursor how to monitor Cursor&rsquo;s activity. Using AI to better understand AI  :^)</p>
<p>Fortunately, I did not need to reverse-engineer Cursor or intercept its network traffic. Cursor exposes a supported <strong>hooks</strong> mechanism at some interesting boundaries of the agent loop. Note that I won&rsquo;t cover the cloud agent scenario here.</p>
<p>This is the first post in a multi-part series:</p>
<ol>
<li>
<p><strong>Spying on Cursor: agent hooks, their payloads and a simple observer</strong> (this post)</p>
</li>
<li>
<p>Rebuilding the conversation: sessions, turns, thoughts, tools, MCP, skills and summaries</p>
</li>
<li>
<p>Extending the spy to Claude Code</p>
</li>
<li>
<p>Look at GitHub Copilot</p>
</li>
</ol>
<h2 id="twenty-one-windows-into-cursor">Twenty-one windows into Cursor</h2>
<p>A hook is a program started by Cursor when a specific event occurs. Cursor writes one JSON object to the program&rsquo;s standard input and reads a JSON response from its standard output.</p>
<p><img alt="HookWorkflow" loading="lazy" src="/posts/2026-08-23_spying-on-cursor-hooks/HookWorkflow.png"></p>
<p>At the time of writing, the <a href="https://cursor.com/docs/hooks">Cursor hooks documentation</a> lists <strong>21 events</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Area</th>
          <th>Hooks</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Session and model loop</td>
          <td><code>sessionStart</code>, <code>sessionEnd</code>, <code>beforeSubmitPrompt</code>, <code>afterAgentThought</code>, <code>afterAgentResponse</code>, <code>preCompact</code>, <code>stop</code></td>
      </tr>
      <tr>
          <td>Tools</td>
          <td><code>preToolUse</code>, <code>postToolUse</code>, <code>postToolUseFailure</code>, <code>beforeShellExecution</code>, <code>afterShellExecution</code>, <code>beforeMCPExecution</code>, <code>afterMCPExecution</code>, <code>beforeReadFile</code>, <code>afterFileEdit</code></td>
      </tr>
      <tr>
          <td>Subagents</td>
          <td><code>subagentStart</code>, <code>subagentStop</code></td>
      </tr>
      <tr>
          <td>Editor and workspace</td>
          <td><code>beforeTabFileRead</code>, <code>afterTabFileEdit</code>, <code>workspaceOpen</code></td>
      </tr>
  </tbody>
</table>
<p>The first group describes the lifetime of a conversation and its turns. A <em>turn</em> is what happens between the time you enter a prompt and when you get the response. The second shows what Cursor executes on behalf of the model. The third exposes delegation to other agents. The last separates inline Tab completions and workspace activity happening during the agent loop.</p>
<p>This is already an important distinction: a hook does not observe &ldquo;the AI&rdquo; as one opaque operation. It is notified of <strong>harness events around the model</strong>.</p>
<h2 id="registering-the-spy">Registering the spy</h2>
<p>Hooks can be registered for one project in <code>.cursor/hooks.json</code>, or for the current user in <code>~/.cursor/hooks.json</code> on Linux or <code>C:\Users\\&lt;current user&gt;\\.cursor\hooks.json</code> on Windows:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span><span class="lnt">21
</span><span class="lnt">22
</span><span class="lnt">23
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;hooks&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;sessionStart&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;C:\\dev\\research\\AI\\HarnessSpy\\CursorSpy\\POC\\src\\CursorSpy.Hook\\bin\\Debug\\net10.0\\CursorSpy.Hook.exe&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;timeout&#34;</span><span class="p">:</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;beforeSubmitPrompt&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;C:\\dev\\research\\AI\\HarnessSpy\\CursorSpy\\POC\\src\\CursorSpy.Hook\\bin\\Debug\\net10.0\\CursorSpy.Hook.exe&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;timeout&#34;</span><span class="p">:</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;preToolUse&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;C:\\dev\\research\\AI\\HarnessSpy\\CursorSpy\\POC\\src\\CursorSpy.Hook\\bin\\Debug\\net10.0\\CursorSpy.Hook.exe&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;timeout&#34;</span><span class="p">:</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>I created a project-level <code>c:\dev\research\AI\HarnessSpy\.cursor\hooks.json</code>file while building and testing the proof of concept implementations for only the prompts related to my research within this folder. I did not want to spy on ALL AI sessions on my machine!</p>
<p>The command field points to the same executable for all 21 events. Cursor starts a new short-lived process each time a hook is triggered. In my case, I hardcoded the full path of my C# console application Debug mode output. However, for a more realistic usage, you can store the script or application hook in a <code>hooks</code> subfolder. It is possible to let Cursor filter when to call the hook using the <code>matcher</code> field but, in my case, I want to be notified of everything.</p>
<p>Note: to troubleshoot empty json payload and figure out which hooks were failing, I added the support of <code>--hook &lt;hook name&gt;</code> as additional command line parameter and increased the timeout to 5 seconds.</p>
<p>The documentation states that Cursor also supports a <code>prompt</code> kind of hook where a small LLM will evaluate what to do when a hook is triggered:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> 
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;hooks&#34;</span><span class="p">:</span> <span class="p">{</span> 
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;beforeShellExecution&#34;</span><span class="p">:</span> <span class="p">[</span> 
</span></span><span class="line"><span class="cl">      <span class="p">{</span> 
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;prompt&#34;</span><span class="p">,</span> 
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;prompt&#34;</span><span class="p">:</span> <span class="s2">&#34;Does this command look safe to execute? Only allow read-only operations.&#34;</span><span class="p">,</span> 
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;timeout&#34;</span><span class="p">:</span> <span class="mi">10</span> 
</span></span><span class="line"><span class="cl">      <span class="p">}</span> 
</span></span><span class="line"><span class="cl">    <span class="p">]</span> 
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>I have to admit that I did not try it&hellip;</p>
<h2 id="do-not-let-the-observer-break-the-observed">Do not let the observer break the observed</h2>
<p>Hooks are not limited to observation. A hook can deny a tool call, rewrite its input, add context or automatically submit a follow-up prompt. A command hook that exits with code <code>2</code> blocks the action. <a href="https://cursor.com/docs/hooks#hook-events">Depending on hooks</a>, you could also allow/deny, change the input/output or even inject additional context!</p>
<p>That power is useful for policy enforcement, but it is the opposite of what I wanted here. A monitoring failure must never block my real work in Cursor or change the agent&rsquo;s decisions.</p>
<p>The hook C# console application starts with only a few lines:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="cl"><span class="k">using</span> <span class="nn">StreamReader</span> <span class="n">input</span> <span class="p">=</span> <span class="k">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">Console</span><span class="p">.</span><span class="n">OpenStandardInput</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="k">new</span> <span class="n">UTF8Encoding</span><span class="p">(</span><span class="n">encoderShouldEmitUTF8Identifier</span><span class="p">:</span> <span class="kc">false</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">detectEncodingFromByteOrderMarks</span><span class="p">:</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="k">await</span> <span class="k">new</span> <span class="n">HookForwarder</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="k">new</span> <span class="n">NamedPipePayloadSink</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="k">new</span> <span class="n">FileHookDiagnostics</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="n">RunAsync</span><span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="n">input</span><span class="p">,</span> <span class="n">Console</span><span class="p">.</span><span class="n">Out</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="n">ConfigureAwait</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>I open standard input explicitly as UTF-8 and enable BOM detection. Prompts, source code and tool results are not restricted to the current Windows console code page, and a BOM at the beginning makes <code>JsonDocument.Parse()</code> reject an otherwise valid payload if it is not removed.</p>
<p>The important behavior is in <code>HookForwarder.RunAsync()</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span><span class="lnt">21
</span><span class="lnt">22
</span><span class="lnt">23
</span><span class="lnt">24
</span><span class="lnt">25
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="cl"><span class="k">try</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kt">string</span> <span class="n">rawPayload</span> <span class="p">=</span> <span class="k">await</span> <span class="n">input</span><span class="p">.</span><span class="n">ReadToEndAsync</span><span class="p">(</span><span class="n">cancellationToken</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="n">rawPayload</span> <span class="p">=</span> <span class="n">rawPayload</span><span class="p">.</span><span class="n">TrimStart</span><span class="p">(</span><span class="err">&#39;\</span><span class="n">uFEFF</span><span class="err">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kt">string?</span> <span class="n">sourceFilePath</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_diagnostics</span><span class="p">.</span><span class="n">SavePayloadAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">sessionId</span><span class="p">,</span> <span class="n">hookEventName</span><span class="p">,</span> <span class="n">rawPayload</span><span class="p">,</span> <span class="n">cancellationToken</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// Wrap the native payload and forward it to the viewer.</span>
</span></span><span class="line"><span class="cl">    <span class="k">await</span> <span class="n">sink</span><span class="p">.</span><span class="n">ForwardAsync</span><span class="p">(</span><span class="n">encodedEnvelope</span><span class="p">,</span> <span class="n">cancellationToken</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">ex</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">await</span> <span class="n">SafeLogAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s">&#34;Unexpected failure while processing the hook payload.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">ex</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">cancellationToken</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">finally</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">await</span> <span class="n">output</span><span class="p">.</span><span class="n">WriteAsync</span><span class="p">(</span><span class="s">&#34;{}&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">await</span> <span class="n">output</span><span class="p">.</span><span class="n">FlushAsync</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="m">0</span><span class="p">;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>There are three rules hidden in this small method:</p>
<ol>
<li><strong>Save first.</strong> The raw payload is persisted before parsing and forwarding, so I still have evidence that the hook ran.</li>
<li><strong>Catch everything.</strong> Parsing, storage, pipe and diagnostic failures are swallowed.</li>
<li><strong>Always return a no-op response.</strong> The process writes exactly <code>{}</code> to stdout and exits with code <code>0</code>.</li>
</ol>
<p>The last point matters. <code>stdout</code> is the hook protocol, not a log stream. Accidentally printing diagnostics there could turn an observer into an instruction to Cursor. Errors therefore go to the <code>cursorspy-hook-errors.log</code> file, and even writing logs is best-effort.</p>
<p>This is my <strong>passive safety contract</strong>: the spy records what it can, but never returns <code>permission</code>, <code>continue</code>, <code>updated_input</code>, <code>additional_context</code> or any other field that could influence the harness.</p>
<h2 id="escaping-from-a-process-that-lives-for-milliseconds">Escaping from a process that lives for milliseconds</h2>
<p>The hook process must terminate quickly to avoid blocking Cursor. Because I wanted to spy live sessions, I built a WPF application to show the triggered hooks and their payload.</p>
<p><img alt="WPFSpyUI" loading="lazy" src="/posts/2026-08-23_spying-on-cursor-hooks/WPFSpyUI.png"></p>
<p>The files stored by the console app hook are used to replay previous sessions but to monitor live sessions, a local named pipe is user: the WPF opens and listens to it and the console sends the hook payload:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span><span class="lnt">21
</span><span class="lnt">22
</span><span class="lnt">23
</span><span class="lnt">24
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="cl"><span class="kd">public</span> <span class="kd">sealed</span> <span class="k">class</span> <span class="nc">NamedPipePayloadSink</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="kt">string</span> <span class="n">pipeName</span> <span class="p">=</span> <span class="s">&#34;HarnessSpy.Ingest.v1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">TimeSpan</span><span class="p">?</span> <span class="n">timeout</span> <span class="p">=</span> <span class="kc">null</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">private</span> <span class="kd">static</span> <span class="k">readonly</span> <span class="kt">byte</span><span class="p">[]</span> <span class="n">NewLine</span> <span class="p">=</span> <span class="p">[(</span><span class="kt">byte</span><span class="p">)</span><span class="sc">&#39;\n&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="kd">private</span> <span class="k">readonly</span> <span class="n">TimeSpan</span> <span class="n">_timeout</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeout</span> <span class="p">??</span> <span class="n">TimeSpan</span><span class="p">.</span><span class="n">FromMilliseconds</span><span class="p">(</span><span class="m">150</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">public</span> <span class="kd">async</span> <span class="n">Task</span> <span class="n">ForwardAsync</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">ReadOnlyMemory</span><span class="p">&lt;</span><span class="kt">byte</span><span class="p">&gt;</span> <span class="n">payload</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">using</span> <span class="nn">var</span> <span class="n">timeoutSource</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">            <span class="n">CancellationTokenSource</span><span class="p">.</span><span class="n">CreateLinkedTokenSource</span><span class="p">(</span><span class="n">cancellationToken</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeoutSource</span><span class="p">.</span><span class="n">CancelAfter</span><span class="p">(</span><span class="n">_timeout</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="k">using</span> <span class="nn">var</span> <span class="n">pipe</span> <span class="p">=</span> <span class="k">new</span> <span class="n">NamedPipeClientStream</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s">&#34;.&#34;</span><span class="p">,</span> <span class="n">pipeName</span><span class="p">,</span> <span class="n">PipeDirection</span><span class="p">.</span><span class="n">Out</span><span class="p">,</span> <span class="n">PipeOptions</span><span class="p">.</span><span class="n">Asynchronous</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">pipe</span><span class="p">.</span><span class="n">ConnectAsync</span><span class="p">(</span><span class="n">timeoutSource</span><span class="p">.</span><span class="n">Token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">pipe</span><span class="p">.</span><span class="n">WriteAsync</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="n">timeoutSource</span><span class="p">.</span><span class="n">Token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">pipe</span><span class="p">.</span><span class="n">WriteAsync</span><span class="p">(</span><span class="n">NewLine</span><span class="p">,</span> <span class="n">timeoutSource</span><span class="p">.</span><span class="n">Token</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>If the viewer is running, it receives one newline-delimited envelope. If it is not running, the connection attempt is abandoned after 150 milliseconds and the hook still succeeds.</p>
<p>On the other side, the WPF application creates <code>HarnessSpy.Ingest.v1</code> name pipe with <code>PipeOptions.CurrentUserOnly</code>, accepts simultaneous short-lived clients and dispatches each valid observation to the UI thread:</p>
<p><img alt="CursorHookDataFlow" loading="lazy" src="/posts/2026-08-23_spying-on-cursor-hooks/CursorHookDataFlow.png"></p>
<p>The envelope adds only capture metadata around the untouched Cursor payload:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;ingressVersion&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;eventId&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;observedAtUtc&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-08-23T08:42:11.765Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;sourceFilePath&#34;</span><span class="p">:</span> <span class="s2">&#34;C:\\...\\Payloads\\hp_...json&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;payload&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;hook_event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;beforeSubmitPrompt&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;conversation_id&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;generation_id&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;prompt&#34;</span><span class="p">:</span> <span class="s2">&#34;Can you investigate this memory leak?&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Keeping the native payload is deliberate. Hook schemas evolve, and flattening only the fields I understand today would silently discard tomorrow&rsquo;s information.</p>
<h2 id="lets-start-with-the-common-fields">Let&rsquo;s start with the common fields</h2>
<p>Every agent hook receives a common set of fields in addition to its event-specific payload:</p>
<table>
  <thead>
      <tr>
          <th>Field</th>
          <th>What it tells me</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>conversation_id</code></td>
          <td>Stable identifier for the conversation across several user prompts</td>
      </tr>
      <tr>
          <td><code>generation_id</code></td>
          <td>Identifier for the current generation; it changes for each user message and becomes the natural turn key</td>
      </tr>
      <tr>
          <td><code>hook_event_name</code></td>
          <td>Name of the triggered hook</td>
      </tr>
      <tr>
          <td><code>model</code>, <code>model_id</code></td>
          <td>Selected model, with a name and a structured identifier when available (not seen during my tests)</td>
      </tr>
      <tr>
          <td><code>model_params</code></td>
          <td>Selected parameters such as thinking, context size or effort.</td>
      </tr>
      <tr>
          <td><code>cursor_version</code></td>
          <td>Cursor version that emitted the payload. I don&rsquo;t know if it is bullet proof but Cursor CLI seems to use a <code>&lt;date&gt;-&lt;id&gt;</code> format such as &ldquo;2026.08.11-e8db854&rdquo; and the IDE uses a <code>&lt;major&gt;.&lt;minor&gt;.xxx</code> format such as &ldquo;3.7.27&rdquo;. This might be a flacky way to make the difference.</td>
      </tr>
      <tr>
          <td><code>workspace_roots</code></td>
          <td>Zero, one or several workspace folders. This is what I used as root in my UI to easily sort my discussions based on which repository or dev folder I was working in. Note that, unlike the cursor CLI, the Cursor IDE adds a leading &lsquo;' character before the Windows pathname that should be removed</td>
      </tr>
      <tr>
          <td><code>user_email</code></td>
          <td>Authenticated user when available</td>
      </tr>
      <tr>
          <td><code>transcript_path</code></td>
          <td>Path to the main conversation transcript</td>
      </tr>
  </tbody>
</table>
<p><code>workspaceOpen</code> runs outside an agent session, so it does not have conversation, generation or model fields. <code>sessionStart</code> also adds <code>session_id</code>, which Cursor documents as the same identifier as <code>conversation_id</code>.</p>
<p>The first two IDs are the key to the next post: <code>conversation_id</code> groups a session and <code>generation_id</code> groups all events produced by one prompt.</p>
<p><code>model_params</code> is useful but should not be confused with the complete request sent to the model. It exposes selected controls such as <code>thinking</code>, <code>context</code> or <code>effort</code>; it does not expose the assembled system prompt and full model context. Even worse, it is noted as optional in the documentation and I&rsquo;ve never seen any during my tests&hellip;</p>
<h2 id="prompts-responses-and-context-pressure">Prompts, responses and context pressure</h2>
<p>The session and model-loop hooks show how a conversation evolves:</p>
<table>
  <thead>
      <tr>
          <th>Event</th>
          <th>Interesting fields</th>
          <th>What I learn</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>workspaceOpen</code></td>
          <td><code>cursor_version</code>, <code>user_email</code>, <code>workspace_roots</code></td>
          <td>Which workspace and Cursor instance became active</td>
      </tr>
      <tr>
          <td><code>sessionStart</code></td>
          <td><code>session_id</code>, <code>is_background_agent</code>, <code>composer_mode</code></td>
          <td>Interactive vs background session and its initial mode (agent, ask,&hellip;)</td>
      </tr>
      <tr>
          <td><code>beforeSubmitPrompt</code></td>
          <td><code>prompt</code>, <code>attachments</code></td>
          <td>What the user typed and which files or rules were attached</td>
      </tr>
      <tr>
          <td><code>afterAgentThought</code></td>
          <td><code>text</code>, <code>duration_ms</code></td>
          <td>Text displayed by Cursor in &ldquo;<em>Thought for xxx s</em>&rdquo;. It preceedes calls to tools</td>
      </tr>
      <tr>
          <td><code>afterAgentResponse</code></td>
          <td><code>text</code>, <code>input_tokens</code>, <code>output_tokens</code>, <code>cache_read_tokens</code>, <code>cache_write_tokens</code></td>
          <td>Completed model response to a prompt after tool calls</td>
      </tr>
      <tr>
          <td><code>preCompact</code></td>
          <td><code>trigger</code>, <code>context_usage_percent</code>, <code>context_tokens</code>, <code>context_window_size</code>, <code>message_count</code>, <code>messages_to_compact</code>, <code>is_first_compaction</code></td>
          <td>When and why Cursor is about to summarize an almost full context</td>
      </tr>
      <tr>
          <td><code>stop</code></td>
          <td><code>status</code>, <code>loop_count</code></td>
          <td>Whether the turn completed, failed or was aborted. I&rsquo;m not using loop so <code>loop_count</code> was always 0</td>
      </tr>
      <tr>
          <td><code>sessionEnd</code></td>
          <td><code>reason</code>, <code>duration_ms</code>, <code>final_status</code>, <code>error_message</code></td>
          <td>How the whole conversation ended. Expect <code>completed</code> when you close the tab in Cursor.</td>
      </tr>
  </tbody>
</table>
<p><code>beforeSubmitPrompt</code> is especially interesting because it fires with the user&rsquo;s prompt:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conversation_id&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;generation_id&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;composer_mode&#34;</span><span class="p">:</span> <span class="s2">&#34;agent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;prompt&#34;</span><span class="p">:</span> <span class="s2">&#34;look for duplicated strings in C:\dev\research\AI\HarnessSpy\CursorSpy\POC\dump\Investigation.dmp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;attachments&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;session_id&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;hook_event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;beforeSubmitPrompt&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;cursor_version&#34;</span><span class="p">:</span> <span class="s2">&#34;3.7.27&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;workspace_roots&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;/c:/dev/research/AI/HarnessSpy/CursorSpy/POC&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;user_email&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;transcript_path&#34;</span><span class="p">:</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>I never got any attachment even when I have explicitly added <code>@filename</code> references or drag an drop from the explorer. The Cursor documentation mentions a type field in addition to file_path:</p>
<pre tabindex="0"><code class="language-json5" data-lang="json5">{
  &#34;prompt&#34;: &#34;&lt;user prompt text&gt;&#34;,
  &#34;attachments&#34;: [
    {
      &#34;type&#34;: &#34;file&#34; | &#34;rule&#34;,
      &#34;file_path&#34;: &#34;&lt;absolute path&gt;&#34;
    }
  ]
}
</code></pre><p>This is what I like when I&rsquo;m starting an investigation on a subject: I end up discovering something on a different topic! The <code>rule</code> type refers to the notion of <a href="https://cursor.com/docs/rules"><em>rules</em> in Cursor</a>. These are .mdc files that <em>provide system-level instructions to Agent. They bundle prompts, scripts, and more together</em>. I will have to dig into that topic later but let&rsquo;s go back to hooks&hellip;</p>
<p>As far as I understand, the <code>afterAgentThought</code> hook is triggered after Cursor has received a first response from the model about your prompt. It provides valuable text for understanding the visible reasoning narrative, but it is an aggregated block selected and rendered by the harness. It should not be described as a lossless copy of a model&rsquo;s private chain-of-thought.</p>
<p>Expect tool calls to follow before <code>afterAgentResponse</code> is triggered. This one contains the final response with <code>input_tokens</code>, <code>output_tokens</code>, <code>cache_read_tokens</code> and <code>cache_write_tokens</code> metrics.</p>
<p>The final <code>stop</code> hook is triggered either when you abort one of your prompt or after the model has returned a response successfully.</p>
<h2 id="which-tools-are-called">Which tools are called?</h2>
<p>Every tool call triggers a <code>preToolUse</code> hook and the <code>tool_name</code> field of its payload contains the name of the tool. Some tools such as <code>Grep</code> are natively implemented by Cursor. The parameters of each tool are listed under the <code>tool_input</code> payload field:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl">  <span class="s2">&#34;tool_name&#34;</span><span class="err">:</span> <span class="s2">&#34;Grep&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;tool_input&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;pattern&#34;</span><span class="p">:</span> <span class="s2">&#34;spy&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;file_path&#34;</span><span class="p">:</span> <span class="s2">&#34;c:\dev\research\AI\HarnessSpy\src\README.md&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;output_mode&#34;</span><span class="p">:</span> <span class="s2">&#34;count&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The read and write tools are triggering additional <code>beforeReadFile</code> and <code>afterFileEdit</code> hooks with a common <code>file_path</code> field. The read one provides the content read (even though the name is prefixed with <code>before</code>) as the <code>content</code> field. For <code>afterFileEdit</code>, the <code>edits</code> field provides a list of <code>old_string</code>/<code>new_string</code> to easily build a diff.</p>
<p>For more generic actions, the <code>Shell</code> tool hook is triggered with <code>command</code>, <code>cwd</code> and <code>sandbox</code> fields such as in this <code>beforeShellExecution</code> payload:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl">  <span class="s2">&#34;command&#34;</span><span class="err">:</span> <span class="s2">&#34;dotnet test &#34;</span><span class="err">CursorSpy.POC.sln</span><span class="s2">&#34;&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;cwd&#34;</span><span class="err">:</span> <span class="s2">&#34;C:\dev\research\AI\HarnessSpy\CursorSpy\POC&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;sandbox&#34;</span><span class="err">:</span> <span class="kc">false</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The calls to MCP server tools are identified in <code>preToolUse</code> <code>tool_name</code> field thanks to the following naming convention <code>&quot;MCP:&lt;tool name&gt;&quot;</code> as show below:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;tool_name&#34;</span><span class="err">:</span> <span class="s2">&#34;MCP:get_duplicated_strings&#34;</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>In addition, a <code>beforeMCPExecution</code> hook follows with more details:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl">  <span class="s2">&#34;tool_name&#34;</span><span class="err">:</span> <span class="s2">&#34;get_duplicated_strings&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;tool_input&#34;</span><span class="err">:</span> <span class="s2">&#34;{&#34;</span><span class="err">dumpPath</span><span class="s2">&#34;:&#34;</span><span class="err">C:\\dev\\research\\AI\\HarnessSpy\\CursorSpy\\POC\\dump\\Investigation.dmp</span><span class="s2">&#34;}&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;mcp_server_name&#34;</span><span class="err">:</span> <span class="s2">&#34;dotnet-dstrings&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;command&#34;</span><span class="err">:</span> <span class="s2">&#34;dotnet-dstrings&#34;</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The<code>afterMCPExecution</code> is triggered with the tool result. There is one parsing trap: <code>tool_output</code>, MCP <code>tool_input</code> and <code>result_json</code> may themselves be <strong>JSON encoded as strings inside the outer JSON payload</strong>. The viewer tries to parse that second JSON layer before displaying its fields. Otherwise Windows paths would be full of doubled backslashes and useful subfields would remain hidden in one long string.</p>
<p>Here is a quick summary of specialized hooks around some tools:</p>
<table>
  <thead>
      <tr>
          <th>Event</th>
          <th>Interesting fields</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>beforeShellExecution</code></td>
          <td><code>command</code>, <code>cwd</code>, <code>sandbox</code></td>
      </tr>
      <tr>
          <td><code>afterShellExecution</code></td>
          <td><code>command</code>, full <code>output</code>, <code>duration</code>, <code>sandbox</code></td>
      </tr>
      <tr>
          <td><code>beforeMCPExecution</code></td>
          <td><code>tool_name</code>, JSON <code>tool_input</code>, and the server <code>url</code> or <code>command</code>; observed payloads can also include <code>mcp_server_name</code></td>
      </tr>
      <tr>
          <td><code>afterMCPExecution</code></td>
          <td><code>tool_name</code>, JSON <code>tool_input</code>, <code>result_json</code>, <code>duration</code></td>
      </tr>
      <tr>
          <td><code>beforeReadFile</code></td>
          <td>Absolute <code>file_path</code>, full <code>content</code>, and file/rule <code>attachments</code></td>
      </tr>
      <tr>
          <td><code>afterFileEdit</code></td>
          <td>Absolute <code>file_path</code> and the list of <code>edits</code></td>
      </tr>
  </tbody>
</table>
<h2 id="subagents-are-not-normal-tool-calls">Subagents are not normal tool calls</h2>
<p>Subagents have their own lifecycle:</p>
<table>
  <thead>
      <tr>
          <th>Event</th>
          <th>Interesting fields based on the documentation (some were not found during my tests)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>subagentStart</code></td>
          <td><code>subagent_id</code>, <code>subagent_type</code>, <code>task</code>, <code>parent_conversation_id</code>, <code>tool_call_id</code>, <code>subagent_model</code>, <code>is_parallel_worker</code>, <code>git_branch</code></td>
      </tr>
      <tr>
          <td><code>subagentStop</code></td>
          <td><code>status</code>, <code>summary</code>, <code>duration_ms</code>, <code>message_count</code>, <code>tool_call_count</code>, <code>modified_files</code>, <code>agent_transcript_path</code></td>
      </tr>
  </tbody>
</table>
<p>This is richer than a simple &ldquo;Task tool was called&rdquo; event mostly because the <code>task</code> field contains the prompt used by the task. It tells you which model ran the delegated task, whether it was a parallel worker, how long it lived, how many tools it used and which files it modified.</p>
<p>However, I was not able to find direct relations between other sessions that I was guessing were related to sub tasks by using <code>tool_call_id</code> or <code>subagent_id</code> and the parent session. Even worse, no <code>sessionStart</code> hook seems to be triggered for these task-related sessions as described in <a href="https://github.com/anthropics/claude-code/issues/27423">this issue</a>&hellip;</p>
<h2 id="what-is-missing">What is missing?</h2>
<p>The received hooks contain much more information than I expected, but their payload do not contain everything:</p>
<ul>
<li><code>model_params</code> is supposed to expose selected settings, not the complete system prompt or assembled request sent to the model but I did not even seen one.</li>
<li><code>afterAgentThought</code> is a harness-provided aggregated thinking block, not guaranteed raw chain-of-thought.</li>
<li>Cursor has no dedicated hook for a question the agent asks the user and the answer the user provides.</li>
<li>A <code>before...</code> hook can make a permission decision, but this passive spy does not see the complete approval UI exchange. A later <code>permission_denied</code> failure is only indirect evidence.</li>
</ul>
<p>The next post will return to these blind spots after rebuilding the events into a session/turn/tool tree.</p>
<h2 id="one-final-warning-the-payloads-are-sensitive">One final warning: the payloads are sensitive</h2>
<p>The captured JSON can contain:</p>
<ul>
<li>user prompts and model responses,</li>
<li>source-file contents and edits,</li>
<li>shell commands and their full output,</li>
<li>MCP parameters and results,</li>
<li>workspace paths, email addresses and transcript paths,</li>
<li>error messages that accidentally include secrets.</li>
</ul>
<p>The named pipe is restricted to the current user, but the POC stores readable plaintext JSON files. Do not publish a <code>Payloads</code> folder as test data without reviewing and redacting it, and delete captures when they are no longer useful.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Cursor hooks could be a useful observability mechanism for three reasons:</p>
<ol>
<li>They cover the important harness boundaries: prompts, model output, tools, MCP, files, subagents and context compaction.</li>
<li>Stable identifiers such as <code>conversation_id</code>, <code>generation_id</code>, <code>tool_use_id</code> and <code>subagent_id</code> provide the raw material needed to reconstruct causality.</li>
<li>A deliberately passive hook can observe those events without becoming part of the agent&rsquo;s behavior or failure path.</li>
</ol>
<p>At this point I had a safe stream of JSON payloads and a viewer receiving them. The next challenge was more interesting: turning that flat stream into conversations, turns, nested and parallel tool calls, skills usage and a useful summary.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://cursor.com/docs/hooks">Cursor hooks documentation</a></li>
<li><a href="https://cursor.com/docs/reference/third-party-hooks">Cursor third-party hooks compatibility</a></li>
<li>Corresponding source code: <code>CursorSpy/POC</code> in my <a href="https://github.com/chrisnas/HarnessSpy/tree/main/POC">HarnessSpy repository</a></li>
</ul>
]]></content:encoded></item></channel></rss>