TL;DR: Edit ~/.claude/settings.json (or .claude/settings.json in a project) and add a hooks entry mapping events (PreToolUse, UserPromptSubmit, SessionStart, Stop) to shell commands. The harness runs the command, not the agent β useful for guardrails and automation.
A hook is a shell command LingCode runs at a specific point in the agent's lifecycle β before a tool call, after a prompt is submitted, when a session ends. Hooks enforce policy: not "please don't do X" written in a CLAUDE.md, but actual code that says no.
There are two ways to influence what the agent does. One is to tell it: in a CLAUDE.md, in a skill, in the prompt itself. Telling works most of the time. It also fails sometimes, because the agent is a model and models are probabilistic. For things that absolutely must happen β or must never happen β telling isn't enough. The rule you wrote down can be ignored under pressure, missed entirely on a fresh chat, or quietly violated when the agent decides the situation is different from the one you described.
The other way is to enforce it: code that runs as the agent works, around its actions. A PreToolUse hook fires before any tool call and can veto it. A UserPromptSubmit hook fires when you send a message and can log, modify, or block it. A PostToolUse hook fires after a successful tool call. A SessionEnd hook fires when the chat closes. Each hook is a shell command β your code β that gets context as environment variables and decides what happens next by its exit code and output.
Use hooks when the wrong outcome is expensive enough that a probabilistic safeguard isn't enough. "Never let the agent push to main." "Always run my linter before committing." "Log every shell command the agent ran today for audit." These are policy questions, not vibes β and hooks are how you make them code.
shell commands that match a dangerous pattern.The events are the same across both the Mac app's chat and the CLI. A hook you write works in either context.
Create .claude/hooks/hooks.json in your project (or ~/.claude/hooks/hooks.json user-wide):
{
"PreToolUse": [
{
"matcher": "shell",
"command": "./.claude/hooks/block-dangerous-shell.sh"
}
],
"UserPromptSubmit": [
{
"command": "./.claude/hooks/log-prompt.sh"
}
]
}
Each event takes an array of hook entries. Each entry has at least a command (the shell command to run) and optionally a matcher (only fire for tool calls matching this pattern). The command can be a script path, an inline expression, anything your shell would accept.
When the hook runs, LingCode sets a set of environment variables prefixed LINGCODE_ (and aliased as CLAUDE_ for Claude Code compatibility). The common ones:
LINGCODE_EVENT β the event name (PreToolUse, etc.)LINGCODE_TOOL_NAME β for tool-related events, the name of the toolLINGCODE_TOOL_ARGS β JSON of the tool's argumentsLINGCODE_PROMPT β for UserPromptSubmit, the raw prompt textLINGCODE_SESSION_ID β a stable ID for the current conversation, useful for log correlationLINGCODE_CWD β the project's working directoryYour hook reads these, does its work, and exits. Standard streams: stdin is empty, stdout is captured for context, stderr surfaces in the chat panel if the hook blocks.
A hook exits 0 to allow the event to continue, non-zero to block it. If a PreToolUse hook exits 1, the tool call is refused; the agent sees the refusal and adapts. If a UserPromptSubmit hook exits 1, the message is not sent. Stderr from a blocking hook becomes a message to the agent explaining why β write a clear one-line reason there.
For modifications (rewriting a prompt, redacting an argument), the hook writes the new value to stdout and exits 0. LingCode replaces the original with the hook's stdout. This is how a "redact secrets" hook works.
git push --force to mainCreate .claude/hooks/block-force-push.sh:
#!/usr/bin/env bash
set -euo pipefail
if [[ "${LINGCODE_TOOL_NAME}" == "shell" ]]; then
cmd=$(echo "$LINGCODE_TOOL_ARGS" | jq -r '.command // ""')
if echo "$cmd" | grep -qE 'git push (-f|--force).*main\b'; then
echo "Refused: --force push to main is not allowed. Open a PR instead." >&2
exit 1
fi
fi
exit 0
Then in .claude/hooks/hooks.json:
{
"PreToolUse": [
{
"matcher": "shell",
"command": "./.claude/hooks/block-force-push.sh"
}
]
}
Make the script executable (chmod +x). From now on, any agent attempt to force-push to main is refused at the tool boundary β the agent sees the stderr message and tries a different approach. There's no way to talk it out of this rule; it's code now.
acceptEdits doesn't bypass hooks. This is intentional β hooks are policy, modes are convenience.
.claude/hooks/ at the project root. Tracked in git so the whole team gets the rule.~/.claude/hooks/. Right for personal habits (e.g. "always lint before committing across all my projects").hooks/ directory. Lets you distribute hooks reusably β but installing such a plugin triggers a consent prompt, because hooks can run shell..sh file with the env vars set manually) before deploying. A failing hook is worse than a missing hook.