extend

Hooks

Hooks run your own local commands at points in the agent loop. They can audit what the agent is doing, feed extra context to the model, rewrite a prompt or a tool's input, and stop an action outright. A hook is a command you write, declared in a file you control, and every hook that a repository could have supplied stays inert until you approve it by name.

Two ways to declare a hook, gated separately

You can declare a hook in a hooks file you own, or inside a skill's SKILL.md frontmatter. These are trusted differently, so each has its own switch. A hooks file is something you sat down and wrote: it is on by default. A skill is installed for its instructions, and a hooks block inside one arrives attached to content you acquired for another reason, so that channel is off until you turn it on.

Config hooks being on by default is safe because two protections do not depend on that switch. A checked-in project squeezy.toml cannot set any hooks key, so a repository cannot enable hook execution for you or contribute hook commands through configuration. And any hook whose declaring file lives inside your workspace stays inert until you approve it with squeezy hooks trust. Cloning a repository arms nothing.

  • $SQUEEZY_HOME/user/hooks.json is your personal hooks file, applied in every workspace, and is a trusted source.
  • <repo>/.squeezy/hooks.json is workspace-controlled: discovered and listed, but never executed until you trust it.
  • An inline [hooks.events] table in your user or repo-local settings works for small setups without a second file.
  • A skill's hooks: frontmatter is the fourth source, gated by [hooks] skills_enabled, which defaults to false.
[hooks]
enabled = true          # default; hooks.json and [hooks.events]
skills_enabled = false  # default; hooks: in SKILL.md frontmatter

What a hook looks like

The shape is the same in every channel: an event name, an optional matcher narrowing which payloads it fires on, and one or more commands. Event keys accept either the PascalCase name or its snake_case alias. Moving a block between a hooks file and a SKILL.md needs no edits, and the same shape is what Claude Code and Codex use for their own hook files.

A hook receives a versioned JSON payload on stdin, and also as an owner-only file named by SQUEEZY_HOOK_PAYLOAD_FILE. Exit status alone gives you the basic allow/deny answer. A hook that exits zero can also print structured JSON to do more: rewrite the prompt or a tool's input, add context or instructions for the next model round, or decide a pending permission request.

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "shell|edit",
        "hooks": [
          { "type": "command",
            "id": "audit",
            "command": "./scripts/audit.sh",
            "timeout": 10,
            "fail_open": false }
        ] }
    ]
  }
}

The 23 events

Events fall into three groups by what your hook is allowed to do with the result. Enforcement-capable events can deny the thing that is about to happen. Mutating events can rewrite an input or add context that reaches the model. Observation events cannot change the outcome at all, and exist so you can log, notify, or measure.

  • Prompt and turn: UserPromptSubmit (deny, rewrite, or add context), UserPromptExpansion (deny), PreTurn (deny or add turn instructions), Stop (request another model round), StopFailure (observe).
  • Tools: PreToolUse (deny or replace the tool input), PostToolUse and PostToolUseFailure and PostTool (add context for the next round), PostToolBatch (add aggregate context once per settled batch).
  • Permissions: PermissionRequest (allow, deny, or leave the prompt alone), PermissionDenied (observe).
  • Context: PreCompact (deny or add compaction instructions), PostCompact (observe), InstructionsLoaded (observe a newly loaded AGENTS.md or SKILL.md body).
  • Subagents: SubagentStart (observe), SubagentStop (request another subagent round).
  • Session: SessionStart and Setup (add context, once per session and once per instance), SessionEnd (observe; always awaited during shutdown), ConfigChange (observe an accepted config update).
  • MCP: Elicitation (deny presentation), ElicitationResult (observe).

Approving what a repository ships

Any hook declared inside your workspace requires explicit approval before it can run, whether it came from a checked-in hooks file or a project skill. Approval binds the exact behavior: the event, the matcher, the command and its arguments, the execution options, the environment variables the hook asked for, and a digest of the declaring file or the whole skill bundle. Change any of it and the hook goes back to needing review, and a running agent rechecks the digest immediately before it executes.

That last part matters more than it sounds. It means approving a hook is not approving a name, it is approving a specific thing the hook does. A pull request that quietly edits an approved hook's command does not inherit the approval.

  • squeezy hooks list shows every declared hook, where it came from, and whether it is trusted, modified, or blocked.
  • squeezy hooks trust <id> approves one hook; --all approves everything currently pending.
  • The TUI offers the same workflow as /hooks, and warns at startup when hooks are pending review or a channel is off.
  • A workspace skill bundle containing a symlink that escapes its own directory is marked blocked and cannot be trusted at all.
squeezy hooks list
squeezy hooks trust hook_ab12cd34
squeezy hooks untrust --all

What a hook is allowed to touch

Hook commands run with the same privileges as Squeezy itself, which is the same trust boundary as the shell tool. That is the reason for the gates and the per-hook approval. Within that boundary, several things are deliberately bounded so a misbehaving hook degrades instead of hanging your session.

The environment a hook receives is scrubbed rather than inherited. It gets a minimal shell bootstrap set, plus exactly the variables it named in env_allowlist, which is the only way a hook can receive something like a token. Those requested names are printed before you approve, so what a hook wants to read out of your shell is disclosed up front rather than discovered later.

  • Timeouts default to 30 seconds and always deny on expiry, regardless of fail_open, because a hung hook is an anomaly.
  • Each hook gets its own process group, so a timeout reaches commands its script spawned, not just the shell.
  • Output is captured with a bound and redacted before it is shown or logged.
  • fail_open = false makes spawn and wait failures deny, so a missing interpreter cannot silently neutralize a policy hook.
  • Observation-only hooks can set async to run off the dispatch path; this is rejected on events whose result is enforced.

Checking your setup

squeezy doctor reports whether the hook shell is reachable, which is the failure users hit first on Windows, and validates every declared hook: missing scripts, missing executable bits, missing shebang lines, and inline shell snippets that will not run as written. It does this without executing anything.

If a hook is not firing, the usual causes are in this order: the channel's gate is off, the hook is workspace-controlled and not yet trusted, or the matcher does not match the payload you expected. The startup notice calls out the first two by name.

squeezy doctor --only hooks:shell
GitHub

Repository access is under construction.

Squeezy's repository is not public yet. The product site and documentation are available here in the meantime.