Skip to content

Hooks

A hook is a command or a script gg runs at one of ten points in a run: around a file write, around a shell command, around a compaction, as an agent starts or tries to stop, and at the session’s two ends. A hook can do two things, and the same two everywhere. It can block the operation it precedes, and it can put text in front of the model.

A hook belongs to the operator rather than to the model. The model is told nothing about a hook, is offered no tool for one, and cannot decline one; a blocked write reaches it as a refusal from the harness. Hooks are not capabilities and never appear in the cap.* query namespace.

A hook’s event decides which of two lists it belongs to.

  • The two session events (session-start, session-end) fire once per run, around the root’s session as a whole. They are declared on the capability set itself, beside the execution ceilings.
  • The other eight fire because a particular agent wrote a file, ran a command, filled its window, started or tried to stop. They are declared on that agent.

The agent half is per profile because the agents of a run are held to different gates. “The build must pass before you may stop” is right for an implementer and wrong for a reviewer whose job is to report that the build fails. Declared once for the run, such a gate would fire for every agent, and each script would have to work out from the agent identity in its payload whether it was meant to fire at all.

A hook declared in the other list’s place fails the launch, naming the move that fixes it. So does a built-in hook whose script id gg does not ship, with the error naming the ids that exist.

A gate that silently does not run is worse than no gate, so the same rule covers every part of a hook’s action gg would otherwise read past: a blank command (which would run nothing, exit 0 and pass every operation it gates), a custom hook carrying no source, an output mode that is not one of the shell’s, and a timeoutSecs that is not a positive number of seconds. Each of them refuses the launch.

A command hook writes its own timeoutSecs. The ceiling a build or a test suite is killed at is a property of that check, so the operator states it and gg reads it. cwd and output stay optional, and each absence is inheritance rather than substitution: the command runs in the agent’s workspace root, and its output follows the agent’s own shell configuration. A custom or built-in script runs under the fixed ceiling gg holds its own hook scripts to, which no field on the hook changes.

{
// The run's own two ends.
"hooks": [
{
"event": "session-end",
"name": "report",
"action": {
"type": "command",
"command": "./notify.sh",
"timeoutSecs": 60,
},
},
],
"agents": [
{
"id": "implementer",
"name": "Implementer",
"hooks": [
{
"event": "agent-stop",
"name": "the build must pass",
"action": {
"type": "command",
"command": "npm run build",
"timeoutSecs": 900,
},
},
{
"event": "pre-write",
"action": { "type": "built-in", "script": "refuse-empty-write" },
},
],
},
{
"id": "reviewer",
"name": "Reviewer",
"hooks": [],
},
],
}

Ten events: four pre/post pairs and the session’s two ends. A pre- event runs before its operation and is the only kind that can stop it; a post- event runs after and can only add to what the model is told.

EventFiresCan blockCan insertPayload beyond the agent facts
pre-writeBefore a file writeyesyespath (absolute), contents
post-writeAfter the file is updatednoyespath, contents, ok
pre-shellBefore a shell command runsyesyescommand
post-shellAfter it has runnoyescommand, ok
pre-compactBefore a compactionnonostrategy
post-compactAfter the window is rewrittennoyesstrategy
agent-startWhen an agent instance startsnoyes—
agent-stopWhen an agent tries to endyesyescall
session-startOnce, before the root’s first turnnoyes—
session-endOnce, after the root finishesnonostatus

Two rows depart from what the pre/post prefix suggests, so both are stated rather than implied.

  • pre-compact is a pre- event that cannot block. Compaction happens because the window is full, so the only useful thing a hook can do there is observe. It cannot insert either: the window it would insert into is the one being rewritten.
  • session-end cannot insert. It fires after the last turn anybody could read it on. It is where a run reports on itself.

A hook that blocks a non-blocking event, or returns a message on an event with no prompt to insert into, is reported to the operator as a misconfiguration and is not honored.

A pre-write payload carries the contents the file will end up with rather than the patch that gets it there, so an edit_file is shown to a hook the way a write_file is. A post-compact insertion is pinned into the rebuilt window, which is the one moment a run can put back something a compaction dropped; every other insertion is ephemeral.

Whatever the event, the JSON a script is handed carries who it is firing for:

{
"event": "pre-write",
"agentId": "agent-3", // the instance's id, its handle in the tree
"agent": "Implementer", // the agent PROFILE's name
// one of: root, issue-implementer, issue-reviewer, subagent
"agentKind": "issue-implementer",
"worktree": { "branch": "gg/issue-1", "path": "/w/issue-1" }, // or null
"path": "/w/issue-1/src/main.rs", // the event's own fields, beside the agent's
"contents": "…",
}

Knowing the profile is not knowing the instance. A profile can be running a dozen times at once, so a script asked to decide about a write needs agentId to tell which of them is writing, and worktree to tell whether that instance works in an isolated tree where the path it is shown means something different from the same path in the main tree. path is absolute for the same reason.

agentKind is the role the instance was dispatched in, not its profile: the same profile implements an issue in one dispatch and reviews one in the next.

The ending gate. A blocking hook here hands its reason back to the model and the session continues, so the model fixes the problem and declares it is done again. It applies to every agent that declares an ending and in both execution modes.

A run that can never satisfy the gate goes on taking turns until it trips a ceiling, which is a diagnosis rather than a silence.

Run a command line, exactly as the shell tool runs one. It receives no input, on the reasoning that the checks that are already commands (npm test, cargo clippy) read the workspace rather than being told about it. A hook that needs to know what is being written wants a script.

A command that exits zero and printed something inserts its output. A non-zero exit blocks, and the reason the model reads carries that same output. Output goes through the agent’s own offloading policy, so a failing test suite that prints a megabyte behaves the way a megabyte of shell output does.

{
"event": "agent-stop",
"name": "tests",
"action": {
"type": "command",
"command": "npm test",
"timeoutSecs": 600, // required on a command hook
"cwd": "web", // optional; relative to the agent's workspace, or absolute
"output": "inline", // optional; absent follows the agent's own shell configuration
},
}

Run one of gg’s own hook scripts by id. A built-in follows the custom contract in every respect, with the source coming from gg instead of the configuration. Each is a worked example to read, copy into a custom hook, and change.

IdWhat it does
traceReport every event it receives back as a message, and continue. The first hook to reach for, because “does this event fire, and with what?” is the question every other hook starts from.
refuse-empty-writeBlock a write whose contents are empty or whitespace, on the reasoning that a model which truncates a file to nothing has lost it rather than meant to empty it. Every other write, and every non-write event, passes.
guard-destructive-shellBlock a shell command that would git push, git reset --hard, or recursively remove a path outside the workspace. A guard rail rather than a sandbox: it matches on the command text.

Run a script the configuration carries verbatim. gg writes it under .gg/hooks/ in the run’s workspace, in a subdirectory of its own declaration site, makes it executable, and runs it with the event payload as its sole argument. The argument is a JSON string rather than a stream, so a script reads its input without a parser for the reading. A leading #! line chooses the interpreter; a script without one is run by sh.

The script must exit 0 and print one decision object on stdout:

{"action": "continue"}
{"action": "block", "reason": "why the operation was refused"}
{"action": "message", "message": "text put in front of the model"}

The three outcomes are exclusive, so the decision is a tagged union rather than a bag of optional fields. A block carrying a message beside it would leave gg guessing whether the message was the reason for the block or an insertion the author also wanted.

Only the last non-empty line is parsed, so a script that logged its way to a decision is read the way it was written.

#!/bin/sh
# Refuse a write to anything under `vendor/`.
python3 - "$1" <<'PY'
import json, sys
event = json.loads(sys.argv[1])
path = event.get("path", "")
if "/vendor/" in path:
reason = f"{path} is vendored; do not edit it."
print(json.dumps({"action": "block", "reason": reason}))
else:
print(json.dumps({"action": "continue"}))
PY

A script that exits non-zero, or prints something gg cannot parse, has judged nothing. Letting the operation through would claim it passed; blocking it would claim it failed. gg does neither: it stops the run, with the script’s own output on the operator stream and a terminal status of hook_error.

This is the one place in gg where a misbehaving subprocess is fatal rather than fed back to the model. The model never asked for the hook, so there is nobody to hand the question to. A command hook’s non-zero exit is different in kind: the check ran and returned a verdict.

The exception is session-end, whose failure is logged and otherwise ignored. Stopping a run that has already finished would change a completed run’s recorded status over a check that was only ever going to observe it.

Several hooks may name the same event. They run in declaration order, and the first one to block stops both the operation and the rest of that event’s hooks. An earlier hook’s message still reaches the model alongside the later hook’s block: the block decides what happens, and a model told why something was refused reads whatever else was being said at the time.

In the console’s configuration editor, the two session events are declared under Session hooks on the Configuration tab, and an agent’s eight are declared on that agent’s Hooks tab. Each list offers only the events its site can hold. Each row picks an event, a kind, and an optional name, and says under the event picker whether that event can be blocked.

FieldMeaning
eventOne of the ten events.
actionThe tagged union above: command, built-in, or custom.
nameAn operator’s label, shown wherever gg reports this hook running or blocking. Optional; an unlabelled hook is reported by what it runs.