Hooks guide

A hook runs your own command at a fixed moment in a session: before a tool call, after a file edit, when you send a prompt, when the agent is about to stop. Hooks are for things that must happen every time, not only when the agent remembers: formatting, blocking risky edits, logging, notifications, quality gates.

This page is task-focused: set up a first hook, then copy worked examples. Every event, input field and output field is listed on the hooks reference.

Your first hook, step by step

Get a desktop notification whenever Syntic Code is waiting for your permission.

Open the hooks menu

Run /hooks in a session. It lists every hook that is loaded, grouped by event, with the file each comes from.

Add the hook

Press a, then pick:

  • Event: Notification
  • Matcher: permission_prompt
  • Command: a command that shows a notification (see the platform table below)
  • Where to save: your user settings, so it works in every project

Or type it in one line:

/hooks add Notification permission_prompt -- osascript -e 'display notification "Syntic Code needs you" with title "Syntic Code"'

Try it

Ask for something that needs approval, for example “delete the tmp folder”. When the permission prompt appears, the notification fires.

SystemNotification command
macOSosascript -e 'display notification "Syntic Code needs you" with title "Syntic Code"'
Linuxnotify-send "Syntic Code" "Syntic Code needs you"
Windows (PowerShell)powershell -Command "[System.Media.SystemSounds]::Exclamation.Play()"

The hook was saved into ~/.syntic/settings.json as:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt",
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Syntic Code needs you\" with title \"Syntic Code\"'" }
        ]
      }
    ]
  }
}

How hooks work

Each event holds a list of groups. A group has an optional matcher that decides when it applies, and a list of hooks to run.

When the event fires, Syntic Code:

  1. finds every group whose matcher fits (for tool events, the matcher is checked against the tool name);
  2. runs the hooks in those groups, passing a JSON description of the event on standard input;
  3. reads what each hook returns: its exit code, and optionally a JSON answer on standard output.

What a hook gets

Every hook receives at least the event name, the session id, the working folder and the permission mode. Tool events add the tool name and its input; PostToolUse adds the tool’s result. A command hook can read it with jq:

#!/bin/sh
file=$(jq -r '.tool_input.file_path // empty')
echo "edited: $file" >> ~/.syntic/edit-log.txt

Command hooks also get SYNTIC_PROJECT_DIR (the project root) and SYNTIC_SESSION_ID in their environment. Your sign-in is never passed to hooks.

What a hook can send back

Exit codeEffect
0Fine. On SessionStart and UserPromptSubmit, whatever the hook prints is added as context for the agent.
2Block. The text on standard error is the reason, and the agent sees it. What “block” means depends on the event.
Anything elseA non-blocking error: you see it, the session carries on.

For finer control, print one JSON object on standard output, for example:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Production config is read-only. Edit config/staging.yaml instead."
  }
}

The full list of answers each event accepts is on the hooks reference.

Matchers

MatcherMatches
none, "" or *Everything.
Edit|Write or Edit,WriteExactly those names.
mcp__github__.*Anything else is a regular expression.

Some events ignore the matcher (for example Stop and UserPromptSubmit); see the reference for which field each event matches on.

On tool events, a hook can also carry an if written like a permission rule, to narrow it to certain inputs: "if": "Bash(git push*)".

Where hooks live

WhereFileWhen it applies
You, everywhere~/.syntic/settings.json (hooks key) or ~/.syntic/hooks.jsonAlways.
The project, shared.syntic/settings.json or .syntic/hooks.jsonOnce you trust the folder.
The project, just you.syntic/settings.local.jsonOnce you trust the folder.
Your organizationManaged settingsAlways (Managed settings).
A pluginhooks/hooks.json in the pluginWhile the plugin is on.
A skill, command or agentA hooks: block in its frontmatterOnly while it runs.

Hooks from every place are combined; one does not replace another.

Worked examples

Format every file the agent edits

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "f=$(jq -r '.tool_input.file_path'); case \"$f\" in *.ts|*.tsx|*.js|*.json|*.md) npx prettier --write \"$f\" ;; esac",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Put it in .syntic/settings.json so the whole team gets it. Swap in gofmt -w, black, rustfmt or whatever your project uses.

Block edits to protected files

Save this as .syntic/hooks/protect.sh and make it executable:

#!/bin/sh
# Refuse edits to secrets, lock files and generated code.
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
  *.env|*.env.*|*package-lock.json|*/generated/*)
    echo "Do not edit $file. It is protected by a project hook." >&2
    exit 2
    ;;
esac
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|MultiEdit|Write",
        "hooks": [{ "type": "command", "command": "\"$SYNTIC_PROJECT_DIR\"/.syntic/hooks/protect.sh" }]
      }
    ]
  }
}

Exit code 2 on PreToolUse stops the tool call, and the agent reads the reason and adjusts.

Stop risky shell commands

Narrow a hook to certain commands with if, so the script only runs when it matters:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(*--force*)",
            "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Force flag: please confirm.\"}}'"
          }
        ]
      }
    ]
  }
}

ask forces a permission prompt even where your rules or mode would have allowed the command silently. Use deny to refuse outright.

Give the agent context at the start

Anything a SessionStart hook prints is handed to the agent:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "echo 'Recent commits:'; git log --oneline -10; echo; echo 'Open TODOs:'; grep -rn 'TODO' src | head -20" }
        ]
      }
    ]
  }
}

Keep the output short: it uses context in every session. For text that should come with each prompt instead, use UserPromptSubmit the same way.

Keep going until the tests pass

A Stop hook runs when the agent is about to end its turn. Blocking sends it back to work with your reason:

#!/bin/sh
# .syntic/hooks/tests-must-pass.sh
input=$(cat)
# Avoid looping forever if the agent cannot fix it.
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
if ! npm test --silent >/tmp/syntic-test.log 2>&1; then
  echo "Tests are failing. Fix them before you finish:" >&2
  tail -30 /tmp/syntic-test.log >&2
  exit 2
fi
{
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "command": "\"$SYNTIC_PROJECT_DIR\"/.syntic/hooks/tests-must-pass.sh", "timeout": 300 }] }]
  }
}

A Stop hook can send the agent back at most 8 times in a row; after that the turn ends anyway. For an open-ended “keep working until X” without a script, see Goals.

Approve safe prompts automatically

A PermissionRequest hook runs just before a permission prompt would appear, and can answer it:

#!/bin/sh
# Auto-approve read-only git commands; leave everything else to the user.
cmd=$(jq -r '.tool_input.command // empty')
case "$cmd" in
  "git status"*|"git diff"*|"git log"*)
    echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
    ;;
esac
exit 0
{
  "hooks": {
    "PermissionRequest": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "~/.syntic/hooks/approve-git-reads.sh" }] }]
  }
}

Printing nothing leaves the prompt to you. For simple cases, an allow rule ("allow": ["Bash(git status)"]) does the same without a script.

Log every settings change

{
  "hooks": {
    "ConfigChange": [
      {
        "hooks": [
          { "type": "command", "command": "jq -c '{time: now | todate, source, file_path}' >> ~/.syntic/config-audit.log" }
        ]
      }
    ]
  }
}

A ConfigChange hook can also block the change from being applied by exiting with 2.

React to file changes

A SessionStart or CwdChanged hook can ask to be told when files change, by answering with watchPaths. A FileChanged hook then runs on each change:

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"watchPaths\":[\".env\"]}}'" }] }
    ],
    "FileChanged": [
      { "matcher": ".env", "hooks": [{ "type": "command", "command": "echo '{\"systemMessage\":\".env changed. Restart the dev server to pick it up.\"}'" }] }
    ]
  }
}

Hooks that ask a model

Not every check fits a script. Two hook types ask a model to judge instead:

typeDoes
promptSends your rule and the event to a model, which answers {"ok": true} or {"ok": false, "reason": "…"}.
agentLike prompt, but a small read-only helper may look at the project first.

ok: false blocks, with the reason passed on. The event JSON replaces $ARGUMENTS in your prompt.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Here is the end of a coding turn: $ARGUMENTS. If the agent changed code but did not run any tests, answer ok=false with the reason 'run the tests first'. Otherwise ok=true."
          }
        ]
      }
    ]
  }
}

Set model on the hook to choose which model judges.

Hooks that call a URL

An http hook sends the event JSON to a URL with a POST request and reads the answer like a command hook’s output:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "http",
            "url": "https://audit.internal.example/syntic",
            "headers": { "Authorization": "Bearer ${AUDIT_TOKEN}" },
            "allowedEnvVars": ["AUDIT_TOKEN"]
          }
        ]
      }
    ]
  }
}

Only variables listed in allowedEnvVars are filled into headers. Redirects are not followed. An organization can limit which URLs may be called with allowedHttpHookUrls.

Useful options

OptionForDoes
timeoutAllSeconds before the hook is abandoned (default 60).
statusMessageAllText shown in the session while the hook runs.
onceAllRun only the first time it matches in a session.
asyncCommandStart it and do not wait (for logging, notifications).
asyncRewakeCommandRun in the background; if it exits with 2, its output reaches the agent later.
shellCommandbash or powershell.
argsCommandRun a program directly with these arguments, without a shell.

Hooks and permissions

Hooks work inside the permission system, not around it:

  • A deny rule in settings always wins. No hook can allow what a rule denies.
  • An ask rule always prompts, even when a hook says allow.
  • Otherwise a hook’s deny refuses the call, ask forces a prompt, and allow skips the prompt the mode would have shown.

Turn hooks off

SwitchEffect
/hooks, then dRemove one hook.
"disableAllHooks": true in your settingsOnly your organization’s hooks run.
--bareNo hooks from settings files for this run.
SYNTIC_DISABLE_HOOKS=1No hooks at all.
syntic --safe-modeHooks (and plugins, skills, MCP servers and memory) off for a troubleshooting session.

Organization-level switches (allowManagedHooksOnly, managed disableAllHooks) are on Managed settings.

Limits

  • A hook that fails to start, times out, or prints an answer that cannot be read is skipped. It never blocks anything by failing.
  • Stop and SubagentStop hooks can send the agent back at most 8 times in a row.
  • TeammateIdle hooks can send a teammate back at most 3 times in a row (Agent teams).
  • Frontmatter hooks in skills, commands and agents can only be command hooks.

Troubleshooting

The hook does not run

  • Check /hooks. If the hook is not listed, the file was not read: project files only load in a trusted folder, and a file with broken JSON is skipped (/doctor reports it).
  • Check the matcher. Names are case-sensitive: Bash, not bash. MCP tools are named mcp__<server>__<tool>.
  • Check the event. PostToolUse only fires after a tool succeeds; failures fire PostToolUseFailure.
  • Check nothing turned hooks off: disableAllHooks, --bare, --safe-mode or SYNTIC_DISABLE_HOOKS.

The hook shows an error

Run the command yourself with a sample event to see what goes wrong:

echo '{"tool_name":"Edit","tool_input":{"file_path":"src/app.ts"}}' | .syntic/hooks/protect.sh; echo "exit $?"

Common causes: the script is not executable, jq is not installed, or a relative path that only works from another folder. Use $SYNTIC_PROJECT_DIR for paths inside the project.

The JSON answer has no effect

A JSON answer is only read when standard output contains just that JSON object. If your shell startup files print text (a greeting, a version banner), the output no longer looks like JSON and is treated as plain text. Keep startup files quiet for non-interactive shells, or run the hook with "args" to skip the shell.

The Stop hook keeps the agent going forever

Check stop_hook_active in the input, as in the example above, and let the turn end when it is true. After 8 blocks in a row the turn ends anyway.

See what hooks did

  • syntic --debug logs every hook run, its exit code and its output.
  • In -p runs, --output-format stream-json --verbose --include-hook-events adds a record for each hook.
  • A statusMessage on the hook shows in the session while it runs.