Skip to content
Chock
Hooks (14 of 30)

Hooks

A hook is a program a project runs at a point in a session: a check before the agent writes a file, a formatter after it, a test suite before a session starts.

.{
    .hooks = .{
        .{
            .name = "tests-pass",
            .on = "tool.before",
            .tool = "request_action",
            .run = .{ "zig", "build", "test" },
            .timeout_ms = 120000,
            .output = "refuse",
        },
    },
}

That one runs the tests before the agent may ask for its work to be applied, and refuses the ask if they fail.

A hook is not a way around anything

A hook runs as a command, through the gate every tool call goes through. The same exec.* rule decides it, the same sandbox holds it, the same redaction covers what it wrote, and the log holds its call and its result beside the agent’s own.

What a hook can do What it cannot do
refuse something that has not happened permit anything
tell the agent what it found reach a path the sandbox does not grant
end a session before the first turn run a program the policy refuses

There is no output that permits. A hook may narrow what the policy decided and never widen it, which is the rule policy.md states for everything else an agent or a project can say.

run is a list and never a line, so nothing parses a quote, a semicolon or a backtick:

.run = .{ "./scripts/check", "--strict" },

A shell is yours to ask for by name, and it is then a program like any other, under the same exec.* rule:

.run = .{ "sh", "-c", "test -z \"$(git status --porcelain)\"" },

A hook that cannot run refuses. If the policy refuses its program, or the program cannot start, a hook whose output is refuse refuses what it fired on. A check that did not happen is not a check that passed.

The fields

Field Meaning
name what a refusal and the log call it. Lowercase letters, digits and single hyphens, and two hooks cannot share one
on session.start, session.end, tool.before or tool.after
tool the one tool this is about, for the two tool triggers. Leave it out for every tool
run the program and its arguments, as a list
inputs which facts reach the program, from trigger, tool, action and session
timeout_ms give up after this long. At most 600000, and 30000 by default
output ignore, refuse or note

At most eight hooks, and at most sixteen words in a run.

When each one runs

Trigger When refuse note
session.start before the first turn ends the session no
tool.before before a tool call is decided the call never runs yes
tool.after once a tool call has run no yes
session.end after the last turn, and after every subagent no no

A call that never ran fires no tool.after hook, whether a hook refused it, the policy refused it, or the agent holds no tools at all. There is nothing for such a hook to be about.

An output a trigger cannot carry is refused when the file is read, rather than doing nothing at runtime. refuse needs a moment where something has not happened yet, and note needs a tool result to carry the words.

What a hook is told

A hook is told only what it asked for, as arguments:

.{
    .name = "check-a-write",
    .on = "tool.before",
    .tool = "write_file",
    .inputs = .{ "tool", "action" },
    .run = .{ "./scripts/check" },
}

runs ./scripts/check --tool write_file --action call.write_file.

The arguments the agent gave the tool are not among the inputs. A hook decides by the kind of call and by the state of the workspace it can read, and not by text the model wrote.

Input What it holds
trigger tool.before, and so on
tool the tool the moment was about
action the action name the call is gated under, the same name a policy rule is written against
session this session’s identifier

An input with nothing in it is left out, so a program never has to tell an empty value from a missing one.

What the log holds

Each run appends a hook.run event, beside the ordinary tool.call and tool.result for the program itself:

{"kind":"hook.run","name":"tests-pass","trigger":"tool.before","tool":"request_action","action":"exec.devshell.zig","outcome":"ran","errored":true,"refused":true}

outcome is ran, refused_by_policy or could_not_run. chock sessions show prints a line for each.

The program’s own call carries the id hook:<name>:<what it was about>, which is the call it fired on or the trigger where there was none. So one hook that fires on many calls writes an id for each, and the terminal says hook <name>: rather than $ on that line.

A session that a session.start hook refused ends with the reason refused_by_hook and exits 3, the code a refusal takes.

What this is not for

A hook is the project’s own check, so a refusal is not a question and there is nothing to approve. Where you want a person to decide, write a policy rule that says ask: actions.md has the names, and approvals.md has what a person sees.