This guide covers how to develop, modify, and test hooks in Salesforce Claude Code (SCC). Hooks are lifecycle scripts that run automatically at specific points during a Claude Code session, providing validation, warnings, quality checks, and session management.
SCC hooks fire at seven lifecycle points defined in hooks/hooks.json:
| Event | When It Fires | Use Cases |
|---|---|---|
| SessionStart | When a new Claude Code session begins | Display project context, detect SF CLI version, list connected orgs |
| PreToolUse | Before any tool executes (Read, Write, Edit, Bash, etc.) | Block dangerous commands, validate SF CLI usage, warn before destructive operations |
| PostToolUse | After a tool executes successfully | Check governor limits on edited Apex, warn about console.log, run quality gates |
| PostToolUseFailure | After a tool execution fails | Track MCP failures, attempt server reconnection |
| PreCompact | Before context window compaction | Save session state so critical information survives compaction |
| Stop | When the agent stops (user presses Escape or task completes) | Summarize changes, check for leftover console.log, persist session, track costs |
| SessionEnd | When the session fully terminates | Mark session end in state store |
Every hook consists of two parts: an entry in hooks/hooks.json and a script file in scripts/hooks/.
Each lifecycle event contains an array of hook definitions:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" governor-check standard \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/governor-check.js\"",
"async": true,
"timeout": 10
}
],
"description": "Check edited Apex files for governor limit violations (SOQL/DML in loops)"
}
]
}
}Fields:
matcherβ Which tool triggers this hook. Values:"Bash","Edit","Write","Read","Edit|Write"(regex OR),"*"(all tools). Only used inPreToolUse,PostToolUse, andPostToolUseFailure.hooks[].typeβ Always"command"for script-based hooks.hooks[].commandβ The shell command to execute. Use${CLAUDE_PLUGIN_ROOT}for the plugin root path.hooks[].asyncβ Whentrue, the hook runs without blocking the agent. Most hooks should be async.hooks[].timeoutβ Maximum execution time in seconds. The hook is killed if it exceeds this.descriptionβ Human-readable description of what the hook does. Required by CI validation.
Hook scripts are Node.js CommonJS modules in scripts/hooks/. There are two patterns:
Modern pattern (recommended) -- exports a run() function:
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
const filePath = String(input.tool_input?.file_path || '');
// Your hook logic here
if (shouldWarn(filePath)) {
process.stderr.write('[SCC MyHook] Warning: something detected\n');
}
} catch {
// Always handle errors gracefully -- never crash
}
return rawInput; // Pass stdin through to stdout unchanged
}
// Support both direct execution and require()
if (require.main === module) {
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
raw += chunk.substring(0, MAX_STDIN - raw.length);
}
});
process.stdin.on('end', () => {
const result = run(raw);
process.stdout.write(result);
});
}
module.exports = { run };Legacy pattern -- reads stdin directly (still supported):
#!/usr/bin/env node
'use strict';
const readline = require('readline');
const MAX_STDIN = 1024 * 1024;
let rawInput = '';
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', line => {
if (rawInput.length < MAX_STDIN) rawInput += line + '\n';
});
rl.on('close', () => {
let input = {};
try {
input = JSON.parse(rawInput.trim() || '{}');
} catch {
process.exit(0);
}
// Your hook logic here
process.exit(0);
});Key conventions:
- Output warnings/diagnostics to stderr (
process.stderr.write()) -- these are shown to the user. - Output the passthrough data to stdout (
process.stdout.write()) -- this is the hook's return value. - Always return the original stdin data unless you intend to modify the tool input/output.
- Exit with code 0 on success or non-fatal warnings. A non-zero exit code blocks the tool execution (for PreToolUse hooks) or signals failure.
Most hooks are wrapped with run-with-flags.js to control which hooks run based on the user's profile setting. This prevents aggressive hooks from running on users who want a lightweight experience.
| Profile | Level | Description |
|---|---|---|
minimal |
1 | Only essential hooks (session markers, cost tracking, pre-compact state saving) |
standard |
2 | Default. All quality checks, governor detection, SF CLI validation, session persistence |
strict |
3 | Everything in standard plus auto-formatting, type-checking, tmux reminders, sfdx-scanner |
The run-with-flags.js wrapper takes three arguments:
node run-with-flags.js <hook-name> <min-profile> <script-path>
hook-name-- A unique identifier for the hook (used inSCC_DISABLED_HOOKS).min-profile-- The minimum profile required to run this hook (minimal,standard, orstrict).script-path-- Absolute path to the hook script.
The wrapper:
- Reads stdin (the tool input/output JSON).
- Checks if the hook is disabled via
SCC_DISABLED_HOOKS. - Checks if the current
SCC_HOOK_PROFILEmeets the minimum level. - If the hook should run, it either
require()s the script (if it exportsrun()) or spawns it as a child process. - If the hook should be skipped, it passes stdin through to stdout unchanged.
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" post-edit-format strict \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/post-edit-format.js\"",
"async": true,
"timeout": 15
}
],
"description": "Auto-format edited files with Prettier (strict profile only)"
}This hook only runs when SCC_HOOK_PROFILE=strict.
Some hooks run unconditionally (no run-with-flags.js wrapper):
session-start.js-- Always displays project context.pre-tool-use.js-- Always validates SF CLI commands.stop-hook.js-- Always summarizes changes.block-no-verify(npm package) -- Always blocks--no-verifyflag.
| Variable | Default | Description |
|---|---|---|
SCC_HOOK_PROFILE |
standard |
Controls which hooks run. Values: minimal, standard, strict. |
SCC_DISABLED_HOOKS |
(empty) | Comma-separated list of hook names to skip. Example: governor-check,quality-gate. |
CLAUDE_PLUGIN_ROOT |
Auto-detected | Root directory of the SCC plugin installation. Set automatically during install. |
# In your shell profile (.zshrc, .bashrc)
export SCC_HOOK_PROFILE=strict
# Or per-session
SCC_HOOK_PROFILE=minimal claude
# Disable specific hooks
export SCC_DISABLED_HOOKS=post-edit-format,pre-bash-tmux-reminderFollow these steps to add a new hook to SCC.
Create a new file in scripts/hooks/. Use the modern run() export pattern:
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const MAX_STDIN = 1024 * 1024;
function log(msg) {
process.stderr.write(`${msg}\n`);
}
/**
* Main hook logic. Receives raw stdin JSON, returns it unchanged.
*/
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
const filePath = String(input.tool_input?.file_path || '');
// Example: check if the file is an Apex class
if (!filePath.endsWith('.cls') && !filePath.endsWith('.trigger')) {
return rawInput;
}
// Read and analyze the file
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
// ... your analysis logic ...
if (issuesFound) {
log('\n[SCC MyHook] Issues detected:');
log(' [WARNING] Description of the issue');
log(' Fix: How to resolve it\n');
}
}
} catch {
// Graceful failure -- never crash
}
return rawInput;
}
if (require.main === module) {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
raw += chunk.substring(0, MAX_STDIN - raw.length);
}
});
process.stdin.on('end', () => {
const result = run(raw);
process.stdout.write(result);
});
}
module.exports = { run };Edit hooks/hooks.json and add your hook under the appropriate lifecycle event:
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" my-hook-name standard \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/my-hook.js\"",
"async": true,
"timeout": 10
}
],
"description": "Short description of what this hook does"
}Choose the right lifecycle event:
- PreToolUse -- For validation/warnings before the action happens.
- PostToolUse -- For analysis/checks after the action succeeds.
- PostToolUseFailure -- For error recovery after the action fails.
- Stop -- For session-end summaries and cleanup.
Create a test file at tests/hooks/my-hook.test.js. Follow the pattern used by existing tests:
#!/usr/bin/env node
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.join(__dirname, '..', '..');
const hookPath = path.join(pluginRoot, 'scripts', 'hooks', 'my-hook.js');
let passCount = 0;
let failCount = 0;
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
passCount++;
} catch (err) {
console.error(` FAIL ${name}`);
console.error(` ${err.message}`);
failCount++;
}
}
test('my-hook.js: module exists', () => {
assert.ok(fs.existsSync(hookPath), 'my-hook.js not found');
});
if (fs.existsSync(hookPath)) {
const myHook = require(hookPath);
test('my-hook.js: exports run function', () => {
assert.ok(typeof myHook.run === 'function', 'Should export run()');
});
test('my-hook.js: handles empty input gracefully', () => {
const result = myHook.run('{}');
assert.ok(typeof result === 'string');
});
test('my-hook.js: detects the target condition', () => {
// Create a temp file, run the hook, check stderr output
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scc-test-'));
const testFile = path.join(tmpDir, 'TestClass.cls');
fs.writeFileSync(testFile, 'public class TestClass { /* bad pattern */ }');
const captured = [];
const origWrite = process.stderr.write;
process.stderr.write = (msg) => { captured.push(msg); };
try {
const input = JSON.stringify({ tool_input: { file_path: testFile } });
myHook.run(input);
} finally {
process.stderr.write = origWrite;
fs.rmSync(tmpDir, { recursive: true });
}
const output = captured.join('');
assert.ok(output.includes('expected warning text'), 'Should detect the target condition');
});
}
console.log(`\nmy-hook.test.js: ${passCount} passed, ${failCount} failed`);
if (failCount > 0) process.exit(1);For Cursor IDE parity, run npm run build to regenerate .cursor/hooks.json. The adapter layer maps Claude Code lifecycle events to Cursor equivalents automatically.
| Claude Code Event | Cursor Equivalent |
|---|---|
| SessionStart | session-start.js |
| PreToolUse (Bash) | before-shell-execution.js |
| PreToolUse (Write/Edit) | before-read-file.js / before-submit-prompt.js |
| PostToolUse (Write/Edit) | after-file-edit.js / after-tab-file-edit.js |
| PostToolUse (Bash) | after-shell-execution.js |
| PreToolUse (MCP) | before-mcp-execution.js |
| PostToolUse (MCP) | after-mcp-execution.js |
| Stop | stop.js |
| SessionEnd | session-end.js |
| PreCompact | pre-compact.js |
node scripts/ci/validate-hooks.jsThis validator checks that:
hooks/hooks.jsonis valid JSON and conforms toschemas/hooks.schema.json.- Every script referenced in hooks.json actually exists on disk.
- Every hook entry has a
descriptionfield.
node tests/run-all.jsMatchers use regex-style patterns to target specific tools:
| Matcher | Targets |
|---|---|
"Bash" |
Shell/terminal command execution |
"Edit" |
File edit operations |
"Write" |
File write (create new file) operations |
"Read" |
File read operations |
"Edit|Write" |
Either Edit or Write (regex OR syntax) |
"*" |
All tool invocations |
Matcher-less hooks (no matcher field) fire unconditionally for that lifecycle event. This is used for SessionStart, PreCompact, Stop, and SessionEnd events.
Every hook should specify a timeout value (in seconds) to prevent runaway scripts from blocking the agent.
Guidelines:
| Hook Type | Recommended Timeout |
|---|---|
| Simple file checks (regex, lint) | 5-10 seconds |
| File analysis (AST parsing, governor checks) | 10-15 seconds |
| External tool invocation (Prettier, TypeScript compiler) | 15-30 seconds |
| Network operations (MCP health checks) | 10-15 seconds |
| Scanner/static analysis (sfdx-scanner PMD) | 30-45 seconds |
If a hook exceeds its timeout, run-with-flags.js kills the process and writes a timeout message to stderr. The agent continues normally.
Claude Code passes JSON to hooks via stdin and reads their response from stdout.
PreToolUse stdin:
{
"tool_name": "Bash",
"tool_input": {
"command": "sf project deploy start --source-dir force-app/"
}
}PostToolUse stdin:
{
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/AccountService.cls",
"old_string": "...",
"new_string": "..."
},
"tool_output": "File edited successfully"
}Stdout: Return the original stdin JSON unchanged (passthrough). For PreToolUse hooks that need to block execution, exit with a non-zero code.
Stderr: Write diagnostic messages, warnings, and recommendations. These are displayed to the user in the Claude Code interface.
-
Fast execution. Hooks run on every tool invocation. Keep execution under 100ms for common-path hooks. Use early returns when the hook does not apply (wrong file type, wrong tool, etc.).
-
Graceful failure. Never let a hook crash or throw an unhandled exception. Wrap all logic in try/catch. A broken hook should fail silently, not break the user's workflow.
-
No blocking on async hooks. Set
"async": truefor all hooks except those that must block tool execution (likeblock-no-verify). Blocking hooks freeze the agent until they complete. -
Limit stdin reads. Cap stdin reads at 1MB (
MAX_STDIN = 1024 * 1024) to prevent memory issues with large tool outputs. -
Use stderr for output. All user-facing messages go to stderr. Stdout is reserved for the passthrough protocol.
-
Prefix messages. Always prefix stderr output with
[SCC HookName]so users can identify which hook produced the message. -
Respect profiles. Use
run-with-flags.jsfor any hook that is not universally needed. Assign the lowest profile level that makes sense:minimal-- Essential infrastructure (session markers, cost tracking).standard-- Quality and safety checks most developers want.strict-- Aggressive checks for teams with strict standards.
-
Test with temp files. Hook tests should create temporary files, run the hook, and clean up. Never depend on files existing in the repo.
-
Keep hooks stateless. Hooks should not write to shared files or maintain global state between invocations. Use environment variables or the SCC state store for persistence.
-
Document the hook. Every hooks.json entry requires a
description. Make it concise but clear about what the hook checks and what action it takes.
Some hooks use run-with-flags-shell.sh instead of the Node.js wrapper, for hooks written as shell scripts:
{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh\" \"pre:observe\" \"scripts/hooks/learning-observe.sh\" \"standard,strict\"",
"async": true,
"timeout": 10
}],
"description": "Capture tool use observations for continuous learning"
}The shell wrapper follows the same profile-gating logic but for Bash scripts. The third argument is a comma-separated list of profiles that should run the hook.
- session-start.js -- Detects sfdx-project.json, SF CLI version, connected orgs. Runs unconditionally.
- block-no-verify -- Blocks
--no-verifyflag on git commands. No profile gating. - mcp-health-check.js -- Checks MCP server health before MCP tool calls. Standard profile.
- observe.sh -- Captures tool use events for continuous learning. Standard/strict profiles.
- pre-tool-use.js -- Validates SF CLI commands (deprecation/danger warnings). No profile gating.
- doc-file-warning.js -- Warns about non-standard documentation files (Write matcher). Standard profile.
- sfdx-validate.js -- Validates SFDX commands for best practices. Standard profile.
- pre-bash-git-push-reminder.js -- Reminds to review changes before git push. Standard profile.
- sfdx-scanner-check.js -- Runs sfdx-scanner PMD analysis before deploy/push. Standard/strict profiles.
- pre-bash-tmux-reminder.js -- Suggests tmux for long-running SF CLI commands. Strict profile.
- suggest-compact.js -- Tracks tool call count, suggests compaction. Standard profile.
- observe.sh -- Captures tool results for continuous learning. Standard/strict profiles.
- post-write.js -- Reminds about test coverage after Apex/LWC file writes. No profile gating.
- quality-gate.js -- Runs quality checks on edited Apex/LWC files. Standard profile.
- governor-check.js -- Checks for governor limit violations (SOQL/DML in loops). Standard profile.
- post-edit-console-warn.js -- Warns about console.log in JS/LWC files. Standard profile.
- post-edit-format.js -- Auto-formats with Prettier. Strict profile.
- post-edit-typecheck.js -- Type-checks TypeScript/LWC files. Strict profile.
- post-bash-build-complete.js -- Detects build/deploy completion. Standard profile.
- post-bash-pr-created.js -- Detects PR creation and logs review command. Standard profile.
- mcp-health-check.js -- Tracks failed MCP calls, marks unhealthy servers. Standard profile.
- pre-compact.js -- Saves session state before context compaction. Minimal profile.
- stop-hook.js -- Summarizes Salesforce changes and suggests next steps. No profile gating.
- check-console-log.js -- Checks for console.log in modified files. Standard profile.
- session-end.js -- Persists session summary. Standard profile.
- cost-tracker.js -- Tracks token usage and estimated costs. Minimal profile.
- evaluate-session.js -- Evaluates session for extractable patterns. Standard profile.
- session-end-marker.js -- Non-blocking session end lifecycle marker. Minimal profile.