Skip to content

The Edge of the Cyber World See the latest

Apps

Build a Cursor Hooks VS Code Extension: 13 Steps [2026]

Searches for “cursor hooks” have climbed steadily through 2026 as more engineering teams push Cursor past the solo-developer stage into shared, policy-governed environments. The feature itself is straightforward: a hook is a shell command that Cursor’s agent runs at a defined point in its loop, before a prompt goes out, before a shell command executes, after a file gets edited, and more. The part that trips teams up is distribution. Cursor ships no API that lets an extension register a hook the way it lets one register an MCP server with vscode.cursor.mcp.registerServer(). Hooks are, in Cursor’s own words from its Python SDK documentation, “file-based only” with “no programmatic hook callback.” That one sentence is the reason this tutorial exists.

If hooks only work through a JSON file sitting in .cursor/hooks.json, the only reliable way to roll a standard hook policy out to twenty engineers without twenty Slack threads about where to paste what is to package the file-writing logic itself inside a real VS Code extension. That is what this guide builds: a working .vsix extension that scaffolds .cursor/hooks.json, drops matching shell scripts into a scripts/ folder, wires a command palette entry for re-applying the policy, and respects the enterprise-to-user precedence order Cursor documents for managed hooks. By the end you will have cloned a real project, packaged it, installed it locally in both VS Code 1.141 and the current Cursor release, and tested that a blocked shell command actually gets blocked.

What Cursor Hooks Actually Are, and Why They Need an Extension

A Cursor Hook is a command Cursor’s agent runs automatically at a specific lifecycle event. According to Cursor’s own hooks documentation, the supported events include beforeShellExecution and afterShellExecution for controlling shell commands, beforeMCPExecution and afterMCPExecution for controlling MCP tool calls, beforeReadFile and afterFileEdit for file access and edits, and beforeSubmitPrompt for validating a prompt before it ever reaches a model. Cursor’s plugin reference and CLI changelog extend that list with session-level and conversation-level events: sessionStart, sessionEnd, stop, preCompact, subagentStart, subagentStop, afterAgentResponse, and afterAgentThought. That is roughly eighteen distinct points where a script can watch, log, or block what the agent is about to do.

The enterprise angle is what makes hooks worth automating. Cursor’s enterprise safety documentation frames hooks explicitly as a security control: scanning prompts for API keys, personal data, or proprietary information before submission, blocking reads of files that hold secrets or customer data, inspecting generated code for SQL injection or leaked credentials before it is written to disk, and intercepting dangerous terminal commands like git push --force or a database DROP statement so they require explicit approval. None of that works unless every machine on the team actually has the same .cursor/hooks.json and the same scripts sitting next to it, which is exactly the kind of thing that drifts the moment it depends on a README someone updates once in March and never again.

This is also where the project rules tutorial on this site and the MCP server registration tutorial diverge from what you are building here. A Cursor Rules extension ships behavioral guidance text to the model. An MCP server extension calls a real registration API to wire up an external tool. A hooks extension does neither: it writes files to disk and makes sure shell scripts are executable, because that is the only distribution path Cursor currently supports for this feature.

Prerequisites and Versions

Confirm each of the following before starting. Version mismatches are the single biggest source of “it works on my machine” bug reports for extension authors, and hooks are new enough that older Cursor builds silently ignore some of the newer event names.

  • Node.js 20 LTS or newer (needed by @vscode/vsce and the Yeoman extension generator)
  • npm 10 or newer, bundled with Node 20
  • Visual Studio Code 1.141 (released October 7, 2026) or newer, for local testing of the packaged extension
  • The current stable release of Cursor, which documents Hooks at cursor.com/docs/hooks and the extension API at cursor.com/docs/extension-api
  • Git, for cloning the sample repository and tracking your extension’s source
  • TypeScript 5.x (installed as a dev dependency by the generator, no separate global install required)
  • A terminal with bash or zsh (this tutorial’s example hook scripts are POSIX shell; a note at the end covers the Windows path)
  • Basic familiarity with JSON and npm scripts; no prior VS Code extension experience is assumed

Check your local versions before moving on:

node --version
npm --version
code --version
git --version

If node --version reports anything below v20, install the current LTS from nodejs.org before continuing. The Yeoman generator used in Step 2 will fail in confusing ways on Node 18 and earlier because of ESM dependency changes upstream.

Step 1: Scaffold the Extension Project

Install the official Yeoman generator for VS Code extensions globally, then scaffold a new TypeScript extension. This generator is maintained by Microsoft and produces the same project layout used by thousands of published extensions, which matters later when you package and publish.

npm install -g yo generator-code
yo code

Answer the prompts as follows:

  • “What type of extension do you want to create?” → New Extension (TypeScript)
  • “What’s the name of your extension?” → cursor-hooks-deploy
  • “What’s the identifier of your extension?” → cursor-hooks-deploy (accept the default)
  • “What’s the description?” → Deploys and manages Cursor Hooks policy files across a team
  • Bundle with webpack? → No, for this tutorial (keeps build output readable while you debug)
  • Initialize a git repository? → Yes
  • Package manager → npm

cd into the new cursor-hooks-deploy folder and open it in VS Code:

cd cursor-hooks-deploy
code .

You now have a working, if empty, extension with package.json, src/extension.ts, and a .vscode/launch.json already configured for debugging. Press F5 right now if you want to confirm it activates before adding any real logic; a new “Extension Development Host” window should open.

Step 2: Define the Hook Policy Bundle

Before writing any extension code, decide what hooks your extension will ship. This tutorial bundles three hooks that map directly to the enterprise use cases Cursor documents: a shell-command guard, a prompt-submission scanner, and a session-end audit logger. Create a policy/ folder at the project root and add the hook definitions.

mkdir -p policy/scripts

Create policy/hooks.json, the template your extension will copy into every workspace:

{
  "hooks": {
    "beforeShellExecution": [
      {
        "command": "./scripts/validate-shell.sh",
        "matcher": "rm -rf|git push --force|DROP TABLE|DROP DATABASE",
        "failClosed": true
      }
    ],
    "beforeSubmitPrompt": [
      {
        "command": "./scripts/scan-prompt.sh"
      }
    ],
    "sessionEnd": [
      {
        "command": "./scripts/audit-session.sh"
      }
    ]
  }
}

The failClosed: true flag on the shell guard matters. Cursor’s hooks documentation notes that by default, a hook that crashes, times out, or exits with a code other than 2 fails open, meaning Cursor logs the failure and lets the action through anyway. For a hook whose entire job is blocking destructive commands, failing open defeats the purpose, so Cursor’s own docs recommend failClosed: true specifically for security-critical hooks on events like beforeMCPExecution and, by the same logic, beforeShellExecution.

Now write the three scripts. policy/scripts/validate-shell.sh reads the command Cursor is about to run from stdin and exits non-zero to block it:

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"command"[[:space:]]*:[[:space:]]*"//;s/"$//')

if echo "$COMMAND" | grep -qiE 'rm -rf /|git push --force|DROP (TABLE|DATABASE)'; then
  echo '{"decision":"block","reason":"Destructive command blocked by team hook policy."}'
  exit 2
fi

echo '{"decision":"allow"}'
exit 0

policy/scripts/scan-prompt.sh performs a lightweight check for strings that look like credentials before a prompt reaches a model:

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(cat)

if echo "$INPUT" | grep -qE 'sk-[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}'; then
  echo '{"decision":"block","reason":"Prompt appears to contain an API key or AWS credential."}'
  exit 2
fi

echo '{"decision":"allow"}'
exit 0

policy/scripts/audit-session.sh appends a line to a local log every time a Cursor session ends, which is the minimum viable audit trail most teams start with:

#!/usr/bin/env bash
set -euo pipefail

mkdir -p "$HOME/.cursor-audit"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) session_end user=$(whoami) cwd=$(pwd)" >> "$HOME/.cursor-audit/sessions.log"
exit 0

Make all three executable, since Cursor needs to run them directly and a non-executable script is the most common reason a hook silently does nothing:

chmod +x policy/scripts/*.sh

Step 3: Write the Deployment Logic in extension.ts

Open src/extension.ts and replace its contents with the deployment command. This is the core of the extension: it copies the policy bundle into whichever workspace is open, merging with any existing .cursor/hooks.json rather than silently overwriting it.

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {
  const deployCommand = vscode.commands.registerCommand(
    'cursorHooksDeploy.apply',
    async () => {
      const folders = vscode.workspace.workspaceFolders;
      if (!folders || folders.length === 0) {
        vscode.window.showErrorMessage('Open a folder before applying the hooks policy.');
        return;
      }

      const workspaceRoot = folders[0].uri.fsPath;
      const cursorDir = path.join(workspaceRoot, '.cursor');
      const scriptsDir = path.join(workspaceRoot, '.cursor', 'scripts');
      const bundleDir = path.join(context.extensionPath, 'policy');

      fs.mkdirSync(cursorDir, { recursive: true });
      fs.mkdirSync(scriptsDir, { recursive: true });

      const bundledHooks = JSON.parse(
        fs.readFileSync(path.join(bundleDir, 'hooks.json'), 'utf8')
      );

      const existingPath = path.join(cursorDir, 'hooks.json');
      let merged = bundledHooks;

      if (fs.existsSync(existingPath)) {
        const existing = JSON.parse(fs.readFileSync(existingPath, 'utf8'));
        merged = {
          hooks: { ...existing.hooks, ...bundledHooks.hooks }
        };
      }

      fs.writeFileSync(existingPath, JSON.stringify(merged, null, 2));

      const scriptFiles = fs.readdirSync(path.join(bundleDir, 'scripts'));
      for (const file of scriptFiles) {
        const src = path.join(bundleDir, 'scripts', file);
        const dest = path.join(scriptsDir, file);
        fs.copyFileSync(src, dest);
        fs.chmodSync(dest, 0o755);
      }

      vscode.window.showInformationMessage(
        `Applied ${scriptFiles.length} hook scripts to .cursor/hooks.json`
      );
    }
  );

  context.subscriptions.push(deployCommand);
}

export function deactivate() {}

The merge step matters more than it looks. Teams rarely start from a blank .cursor/hooks.json; someone has usually already added a formatting hook or a personal afterFileEdit script. Overwriting that silently is how you get the extension uninstalled within a week. The shallow merge here keeps existing event arrays unless your bundle also defines that same event, which is good enough for a tutorial; production versions should append to arrays per event instead of replacing them, since Cursor runs every hook registered for an event, not just the last one defined.

Step 4: Register the Command and a Status Bar Shortcut

Open package.json and add the command contribution plus the files bundle so the policy folder ships inside the packaged extension. Find the "contributes" block and update it:

{
  "contributes": {
    "commands": [
      {
        "command": "cursorHooksDeploy.apply",
        "title": "Cursor Hooks: Apply Team Policy"
      }
    ]
  }
}

Then make sure the policy directory is not excluded from the package. Open .vscodeignore and confirm there is no line matching policy/**; the default generator output only ignores source maps, TypeScript source, and node_modules dev dependencies, so this is usually already safe, but it is worth checking before you package in Step 7.

Press F5 to launch the Extension Development Host, open the Command Palette with Ctrl+Shift+P (Cmd+Shift+P on macOS), and type “Cursor Hooks: Apply Team Policy.” Running it against a test folder should produce the information message from Step 3 and create a populated .cursor/hooks.json.

Step 5: Add Enterprise Precedence Awareness

Cursor’s CLI changelog documents a precedence order for managed hooks: enterprise, then team, then project, then user. An extension that blindly writes to the project-level .cursor/hooks.json without checking whether an enterprise or team policy already governs that workspace risks creating confusing duplicate enforcement. Add a lightweight check before the write step in extension.ts, inside the same command handler, right after the workspace root is resolved:

const teamManagedMarker = path.join(workspaceRoot, '.cursor', '.team-managed');
if (fs.existsSync(teamManagedMarker)) {
  const proceed = await vscode.window.showWarningMessage(
    'This workspace is governed by a team-managed hooks policy. Applying a local bundle may duplicate enforcement. Continue anyway?',
    'Continue',
    'Cancel'
  );
  if (proceed !== 'Continue') {
    return;
  }
}

This is a convention, not an API Cursor enforces for you; there is no hook-specific extension API to query the active precedence tier programmatically, confirmed by the absence of any such method in Cursor’s published extension API reference. The marker file approach gives teams a cheap way to flag “do not auto-apply here” without depending on functionality Cursor has not shipped.

Step 6: Test the Blocking Behavior End to End

With the policy applied to a test workspace, open that same folder in Cursor itself (not the VS Code Extension Development Host; hooks only run inside Cursor’s agent loop, not in plain VS Code, since VS Code has no agent to run the scripts against). Open the Cursor agent chat and ask it to run a destructive command, for example:

Run this in the terminal: git push --force origin main

If the hook deployed correctly, Cursor should surface a blocked action referencing the reason string from validate-shell.sh instead of executing the push. Check the Output panel’s Cursor logging channel, which Cursor’s documentation confirms is where connection errors, authentication failures, and hook-related failures get logged, to confirm the script actually ran rather than failing silently open.

Next, test the audit log. End the Cursor session (close the window or switch workspaces) and check:

cat ~/.cursor-audit/sessions.log

You should see a new line with a UTC timestamp, the current user, and the working directory. If the file does not exist, the most common cause is a missing execute bit on audit-session.sh after it was copied; re-run the deploy command, which re-applies chmod 0o755 on every copy.

Step 7: Package the Extension with vsce

Install Microsoft’s official packaging CLI and produce a .vsix file:

npm install -g @vscode/vsce
vsce package

This produces cursor-hooks-deploy-0.0.1.vsix in your project root. If vsce package errors out asking for a publisher name, open package.json and add a "publisher" field with any identifier you plan to use consistently (you will need to claim that identifier on the Visual Studio Marketplace before a real publish, but packaging locally works without it being claimed yet).

Step 8: Install the Packaged Extension Locally in Both Editors

Install the .vsix in VS Code first, to confirm the package itself is valid:

code --install-extension cursor-hooks-deploy-0.0.1.vsix

Then install the identical file in Cursor. Cursor’s customize page documents that the Customize sidebar lets you add and manage extensions at the user, team, or workspace level, and that official and community plugins install the same way regardless of source, since Cursor reuses the VS Code extensions panel and marketplace format. Open Cursor, go to Customize, choose “Install from VSIX,” and select the same file.

Run the “Cursor Hooks: Apply Team Policy” command from inside Cursor this time (not the Dev Host) against a real test project, and confirm the resulting .cursor/hooks.json is picked up by opening a fresh agent chat in that workspace and trying the destructive-command test from Step 6 again.

Step 9: Add a Pre-Deploy Validation Check

A hooks policy that silently fails to apply is worse than no policy, because teams assume protection exists when it does not. Add a verification command that reads back the deployed .cursor/hooks.json and confirms every referenced script file actually exists and is executable. Add this second command registration inside activate(), after the existing deployCommand block:

const verifyCommand = vscode.commands.registerCommand(
  'cursorHooksDeploy.verify',
  async () => {
    const folders = vscode.workspace.workspaceFolders;
    if (!folders || folders.length === 0) {
      vscode.window.showErrorMessage('Open a folder to verify.');
      return;
    }

    const workspaceRoot = folders[0].uri.fsPath;
    const hooksPath = path.join(workspaceRoot, '.cursor', 'hooks.json');

    if (!fs.existsSync(hooksPath)) {
      vscode.window.showErrorMessage('No .cursor/hooks.json found. Run Apply first.');
      return;
    }

    const config = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
    const missing: string[] = [];

    for (const event of Object.keys(config.hooks || {})) {
      for (const entry of config.hooks[event]) {
        const scriptPath = path.join(workspaceRoot, '.cursor', entry.command.replace('./', ''));
        if (!fs.existsSync(scriptPath)) {
          missing.push(`${event}: ${entry.command}`);
          continue;
        }
        const stats = fs.statSync(scriptPath);
        const isExecutable = !!(stats.mode & 0o111);
        if (!isExecutable) {
          missing.push(`${event}: ${entry.command} (not executable)`);
        }
      }
    }

    if (missing.length > 0) {
      vscode.window.showWarningMessage(`Hook policy issues found: ${missing.join('; ')}`);
    } else {
      vscode.window.showInformationMessage('All hook scripts present and executable.');
    }
  }
);

context.subscriptions.push(verifyCommand);

Register the matching contribution in package.json alongside the existing command entry:

{
  "command": "cursorHooksDeploy.verify",
  "title": "Cursor Hooks: Verify Policy"
}

Step 10: Handle the beforeMCPExecution Case Separately

If your team also uses MCP servers alongside hooks, the beforeMCPExecution event deserves its own hook rather than reusing the shell guard, since Cursor’s hooks documentation treats MCP tool calls and shell commands as distinct event families with different payload shapes. Add a fourth script, policy/scripts/validate-mcp.sh:

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"tool"[[:space:]]*:[[:space:]]*"//;s/"$//')

BLOCKED_TOOLS="delete_database drop_table send_email"

for blocked in $BLOCKED_TOOLS; do
  if [ "$TOOL_NAME" = "$blocked" ]; then
    echo "{"decision":"block","reason":"MCP tool $TOOL_NAME requires manual approval."}"
    exit 2
  fi
done

echo '{"decision":"allow"}'
exit 0

Add it to policy/hooks.json under a new beforeMCPExecution array, set failClosed: true on it exactly as Cursor’s documentation recommends for this event, make it executable with chmod +x, and re-run vsce package to produce an updated .vsix.

Step 11: Publish to a Private or Public Marketplace

For a team-internal tool like this, a private distribution channel usually beats a public marketplace listing. Options, in order of setup effort:

  • Share the .vsix file directly through your existing internal tooling (Slack, a shared drive, an internal wiki) and have teammates run code --install-extension or Cursor’s “Install from VSIX” option
  • Host it in a private Open VSX-compatible registry if your organization already runs one for other internal extensions
  • Publish publicly to the Visual Studio Marketplace via vsce publish, which requires a Microsoft publisher account and an Azure DevOps personal access token, documented in Microsoft’s official publishing guide

For most teams building this specific extension, option one is enough. The whole point is removing manual setup steps, not adding marketplace review latency to the rollout.

Step 12: Automate Re-Deployment on Workspace Open

To stop the policy from going stale as you add new hook events, trigger the apply command automatically when a workspace opens, rather than relying on someone remembering to run it manually after a policy update. Add this to the bottom of activate():

const config = vscode.workspace.getConfiguration('cursorHooksDeploy');
const autoApply = config.get<boolean>('autoApplyOnOpen', false);

if (autoApply) {
  vscode.commands.executeCommand('cursorHooksDeploy.apply');
}

Then add the matching setting to package.json under "contributes":

{
  "configuration": {
    "title": "Cursor Hooks Deploy",
    "properties": {
      "cursorHooksDeploy.autoApplyOnOpen": {
        "type": "boolean",
        "default": false,
        "description": "Automatically apply the bundled hooks policy when a workspace opens."
      }
    }
  }
}

Default this to false for a public release and true only in an internal fork your team installs, since auto-overwriting a developer’s local .cursor/hooks.json without an explicit action is the kind of surprise that generates support tickets.

Step 13: Confirm the Complete Working Project

Your finished project structure should look like this:

cursor-hooks-deploy/
├── package.json
├── src/
│   └── extension.ts
├── policy/
│   ├── hooks.json
│   └── scripts/
│       ├── validate-shell.sh
│       ├── validate-mcp.sh
│       ├── scan-prompt.sh
│       └── audit-session.sh
├── .vscodeignore
└── cursor-hooks-deploy-0.0.1.vsix

Run both commands one final time against a clean test folder to confirm the whole pipeline:

Cursor Hooks: Apply Team Policy
Cursor Hooks: Verify Policy

Expected output from the verify command, assuming everything deployed cleanly:

Information: All hook scripts present and executable.

If you see a warning listing missing or non-executable scripts instead, re-check Step 3’s chmod call and Step 7’s .vscodeignore exclusions before re-packaging.

Common Pitfalls When Building Cursor Hooks Extensions

These five mistakes account for most of the failed deployments teams report when rolling out hook policies through an extension rather than hand-editing files.

  • Assuming hooks run inside VS Code. Hooks are a Cursor agent-loop feature. Opening the same workspace in plain VS Code will not trigger any hook, since there is no agent loop to intercept. Test hook behavior only inside Cursor.
  • Forgetting the execute bit after copying scripts. fs.copyFileSync does not preserve executable permissions reliably across platforms. Always call fs.chmodSync(dest, 0o755) explicitly after every copy, as shown in Step 3.
  • Overwriting instead of merging hooks.json. A naive fs.writeFileSync without first reading the existing file destroys any hooks a developer added locally. Always merge, even if the merge logic is a simple shallow object spread.
  • Leaving failClosed unset on security-critical hooks. The default fail-open behavior means a crashing validation script silently lets the dangerous action through instead of blocking it. Set failClosed: true on every hook whose entire purpose is blocking something.
  • Excluding the policy folder from the packaged .vsix. A stray wildcard in .vscodeignore that matches policy/** will produce a package that activates fine but has nothing to deploy, and the failure is silent until someone checks the Output panel.

Troubleshooting Guide

Work through these checks in order when a deployed hook does not behave as expected.

Symptom Likely Cause Fix
Command “Cursor Hooks: Apply Team Policy” does not appear in the palette Extension failed to activate, or the command ID in package.json does not match the registerCommand call Check the Extension Host developer console for activation errors; confirm both IDs match exactly, including case
Hook script runs but never blocks anything Script exits 0 or 1 instead of exit code 2, or failClosed is unset Confirm the block path uses exit 2 explicitly and failClosed: true is present in hooks.json for that event
Destructive command still executes despite a matching hook .cursor/hooks.json was written to the wrong directory, or an enterprise/team policy is overriding the project file Confirm the file sits at the workspace root under .cursor/, and check for a .team-managed marker or dashboard-configured team hooks
Scripts work on macOS but fail silently on Windows Hook commands point to extensionless shell scripts, which Windows cannot execute directly Ship a matching .ps1, .bat, or .cmd file alongside each .sh script, since Cursor’s IDE release notes confirm it now checks for these matching extensions on Windows
Verify command reports scripts missing immediately after Apply .vscodeignore excluded the policy folder from the packaged .vsix Remove any rule matching policy/** from .vscodeignore and re-run vsce package
Audit log file never appears sessionEnd hook never fired, or HOME is undefined in the script’s execution environment Hardcode an absolute fallback path temporarily to confirm the script itself runs, then fix the HOME resolution
JSON parse error when merging existing hooks.json A developer hand-edited the file with a trailing comma or comment, which standard JSON does not allow Validate the existing file with a JSON linter before attempting the merge, and wrap the read in a try/catch that surfaces a clear error instead of crashing
Extension installs in VS Code but Cursor’s Customize panel does not show it after VSIX install Stale extension host cache after a previous failed install attempt Fully restart Cursor, not just reload the window, then retry the Install from VSIX step

Hook Events Reference Table

Use this as a quick reference when deciding which event to attach new policy logic to.

Event Name Fires When Typical Use
beforeSubmitPrompt Before a prompt is sent to the model Block prompts containing secrets or PII
beforeShellExecution Before the agent runs a terminal command Block destructive shell commands
afterShellExecution After a terminal command completes Log command output for audit purposes
beforeReadFile Before the agent reads a file into context Block reads of files containing secrets or PII
afterFileEdit After the agent writes or edits a file Run a formatter or scan for leaked credentials
beforeMCPExecution Before an MCP tool call executes Require approval for high-risk tool calls
afterMCPExecution After an MCP tool call completes Log external tool activity
sessionStart When a new Cursor session begins Initialize per-session logging context
sessionEnd When a Cursor session ends Write a session summary to an audit log
stop When the agent’s turn ends Trigger automated follow-up loops
preCompact Before conversation history is compacted Archive full context before it is summarized
subagentStart / subagentStop When a subagent begins or finishes Track multi-agent workflows

Advanced Tips for Production Hook Policies

Once the basic extension works, a few refinements separate a demo from something a security team will actually sign off on.

First, replace the grep-based pattern matching in the example scripts with a proper JSON parser inside each hook script, using jq if it is available on target machines, or a small Python or Node helper bundled alongside the shell scripts. The grep approach in this tutorial is intentionally simple so it runs with zero dependencies beyond bash, but it is fragile against multi-line commands or unusual quoting, and a security-critical hook deserves a parser that will not false-negative on an edge case.

Second, consider Cursor’s third-party hooks compatibility layer if your team already maintains Claude Code hook scripts in a settings.json file. Cursor’s CLI changelog confirms it reads and merges Claude Code settings.json hooks automatically, mapping capitalized event names like PreToolUse and Stop to Cursor’s native lowercase equivalents. An extension that detects an existing Claude Code hooks configuration and offers to translate it, rather than requiring a parallel Cursor-specific policy, saves teams from maintaining two nearly identical files.

Third, route the output of every blocking hook through OpenTelemetry if your organization already has an observability pipeline. Cursor’s enterprise documentation defines a wire format for exporting hook events, including a cursor.hook.type field with values matching the event names from this tutorial’s reference table, which means a hook script can both make a local decision and emit a structured event for centralized monitoring in the same execution.

Fourth, version your policy bundle. Add a policyVersion field to the extension’s own output and have the verify command compare it against the version baked into the currently installed .vsix, so a stale installation that has not been updated in three months surfaces that fact instead of silently enforcing an outdated rule set.

Why There’s No registerHook API, and What That Means Going Forward

It is worth being direct about a limitation this tutorial works around rather than papering over. Cursor’s extension API reference documents vscode.cursor.mcp.registerServer() for MCP servers but exposes no equivalent method for hooks. Cursor’s own Python SDK documentation states the point plainly: hooks are file-based only, with no programmatic hook callback, because hooks are designed as “a project policy boundary, not a per-run knob.” That design choice makes sense from Cursor’s side. A policy boundary that any extension could silently alter at runtime through an API call would be a weaker boundary than one that only changes when a file on disk changes, since file changes are visible to version control, code review, and standard filesystem auditing in a way that runtime API calls are not.

Practically, that means the extension built in this tutorial is not a workaround for a missing feature so much as the intended integration pattern for this specific feature. If Cursor adds a programmatic hooks API in a future release, following its changelog at cursor.com/docs/cli/changelog is the way to find out, and migrating this extension to call a real API instead of writing files directly would be a smaller change than it sounds, since the deployment logic in Step 3 is already isolated behind the single cursorHooksDeploy.apply command.

Where Hooks Fit Among Cursor’s Other Customization Layers

Cursor’s documentation groups hooks alongside extensions, MCP servers, rules, skills, subagents, and commands under a single “Customize” umbrella, and it is easy to reach for the wrong one when a new policy requirement shows up. Rules, covered in a separate tutorial on this site, are text that steers how the model reasons about a codebase, things like “always write tests before implementation” or “this repo uses Go modules, not GOPATH.” A rule can be ignored or misread by the model under pressure, because it is guidance, not enforcement. A hook cannot be talked out of blocking a command, because the block happens at the process level before the agent’s output ever reaches execution. That distinction is why security-sensitive policy belongs in a hook and stylistic or architectural guidance belongs in a rule, even though both end up living in a hidden .cursor folder at the project root.

Skills and subagents occupy a different layer again. A skill packages reusable domain knowledge the agent can pull into context on demand, and a subagent is a scoped, separately-configured agent instance for a narrower task. Neither one intercepts an action the way a hook does; both are additive capabilities the main agent chooses to use, whereas a hook runs unconditionally whenever its event fires, whether the agent wants it to or not. MCP servers, the subject of the extension-API tutorial referenced earlier in this guide, add new tools and data sources the agent can call. Hooks do not add capabilities at all. They only watch, log, or veto the capabilities that already exist. Keeping those four roles separate in your head makes it much easier to decide where a new requirement belongs the next time a security review or an incident response asks for “better guardrails around the AI agent,” since that request almost always means a hook, not a rule.

One practical consequence of this layering: an extension that deploys hooks, like the one in this tutorial, pairs naturally with an extension that deploys rules, since a mature rollout usually needs both the soft guidance and the hard enforcement. There is nothing stopping you from merging the two into a single “Cursor Policy” extension that writes both .cursor/rules/ files and .cursor/hooks.json from one command, and plenty of teams end up doing exactly that once the initial hooks-only version proves useful. The command structure in Step 3 and Step 9 of this tutorial was deliberately kept as two small, separable functions specifically so that kind of merge is a copy-paste exercise rather than a rewrite.

Frequently Asked Questions

Does this extension work in plain VS Code, or only in Cursor?
It installs and runs in both, since Cursor shares the VS Code extension host and marketplace format. But the hooks it deploys only take effect inside Cursor’s agent loop. Installing it in plain VS Code will write the files without any functional effect, since VS Code has no agent to trigger the events.

Can I register a hook without writing to .cursor/hooks.json?
Not currently. Cursor’s extension API and Python SDK documentation both confirm hooks are file-based only, with no programmatic registration method as of the current release.

What happens if two hooks are registered for the same event?
Cursor runs all hooks registered for that event, not just the most recently added one, which is why the merge logic in Step 3 appends to existing configuration rather than replacing it wholesale.

Should failClosed be true by default on every hook?
No. Cursor recommends it specifically for security-critical hooks, such as those blocking shell commands or MCP tool calls. A formatting hook or a non-critical logging hook is usually safer left fail-open, so a transient script error does not block a developer’s entire workflow.

Do enterprise-managed hooks override what my extension deploys?
Cursor documents a precedence order of enterprise, then team, then project, then user. An extension deploying at the project level should assume it can be overridden by anything higher in that chain, which is why Step 5 adds a marker-file check rather than assuming the extension has final say.

Can cloud agents use the same hooks.json my extension deploys?
Yes. Cursor’s cloud agent documentation confirms cloud agents run command-based hooks from the repository’s own .cursor/hooks.json, and on Enterprise plans also apply team and enterprise-managed hooks on top of it.

Will this extension work with Claude Code’s hooks format too?
Cursor documents a compatibility layer that reads and maps Claude Code’s capitalized hook event names, such as PreToolUse and Stop, to its own lowercase equivalents. This tutorial’s extension targets Cursor’s native format directly, but the Advanced Tips section covers building a translation layer for teams already standardized on Claude Code’s settings.json format.

Is there a performance cost to running many hooks?
Every hook is a subprocess spawn, so a long chain of hooks on a hot event like afterFileEdit will add latency proportional to how many scripts run and how slow each one is. Keep hook scripts short-lived and avoid network calls inside a hook unless the event genuinely requires one, such as a remote audit log write.

Related Coverage

Source: Tech Insider