Hooks
A hook runs your code at a fixed point in the agent loop, such as before a tool runs or when a session starts. Polytoken calls the hook, hands it a description of what is about to happen, and reads back a decision. A hook can observe the event, add context the model will see, or stop the action outright.
Where hooks live
Section titled “Where hooks live”You define hooks in a hooks.json file. Polytoken reads two of them:
- Global, at
hooks.jsonin$XDG_CONFIG_HOME/polytoken/(default:~/.config/polytoken/), for hooks you want in every project. - Project, at
.polytoken/hooks.jsonin your project, for hooks specific to that project.
Each file is a JSON array of hook entries. Polytoken loads the global entries first, then appends the project entries, and runs them in that order for any event they match.
Writing a hook
Section titled “Writing a hook”A hook entry has four fields:
{ "name": "log-edits", "event": "post_tool_use", "matcher": "file_edit_*", "handler": { "bash": "cat >> /tmp/polytoken-edits.log" }}| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | A string identifying the hook. Polytoken uses the name in logs and in !name negation entries. |
event | string | yes | The point in the loop that fires this hook. One of the names in Events. |
matcher | string | no | A glob that narrows which instances of the event fire the hook. Omit it to fire on every instance. See Matching. |
handler | object | yes | The code to run. See Handlers. |
Polytoken validates the file at load time. It rejects an unknown event name,
an unparseable matcher glob, an unknown handler key, or an entry missing a
required field as a load error. Two mistakes slip past validation: a file whose
root is not a JSON array loads as no hooks, and Polytoken ignores a misspelled
field name inside an entry, so the hook runs as though you had left the field
out.
Events
Section titled “Events”An event is a point in the agent loop. Each event passes its handler a different set of fields and reads back a different decision.
Read the Waits column first. Polytoken waits for a blocking event’s handler and acts on what it returns, so a blocking hook can add context or stop the action. A fire-and-forget hook runs in the background and Polytoken discards what it returns, so use one for side effects such as logging, not for changing what Polytoken does next.
| Event | Waits | Fires |
|---|---|---|
session_start | yes | When a session begins. |
pre_user_prompt | yes | Before Polytoken records a prompt you submit. |
pre_model_turn | yes | Before Polytoken calls the model for a turn. |
post_model_turn | no | After the model finishes a turn. |
pre_tool_use | yes | Before a tool runs. |
post_tool_use | no | After a tool succeeds. |
post_tool_use_failure | no | After a tool fails. |
stop | yes | When the model would finish and Polytoken would hand the turn back to you. |
pre_compaction | yes | Before Polytoken compacts the session. |
post_compaction | yes | After Polytoken compacts the session. |
post_clear | yes | After Polytoken clears context: on /clear, when a plan handoff moves the model into a fresh context, and once at session startup. |
notification | yes | When Polytoken would raise a notification. |
facet_switch | no | After the active facet changes. |
subagent_start | no | After a subagent starts. |
subagent_stop | no | After a subagent finishes. |
interrogative_wants_open | no | When Polytoken registers an answerable prompt for you. |
interrogative_has_closed | no | When a registered prompt leaves the pending set. |
Two events track the prompts Polytoken raises for you. interrogative_wants_open
fires when Polytoken registers an answerable prompt: a permission ask, an
ask_user_question batch, a plan handoff, a goal confirmation, a facet switch
confirmation, or an extension approval. The event triggers at registration,
before delivery, so a prompt that never reaches your client has still announced
its open. The payload carries the prompt id, the interrogative id, the kind, a
bounded preview of the question, and the subagent handle when the request
originates inside a subagent.
interrogative_has_closed fires exactly once per registration when a prompt
leaves the pending set, so every close traces to exactly one
interrogative_wants_open. A prompt leaves the set answered, withdrawn without
an answer (your cancel, a permission-monitor change, a timeout), or aborted
(turn cancellation or shutdown). The payload carries the interrogative id, the
kind, the outcome, and the subagent handle when the request originated inside a
subagent. Polytoken deliberately keeps some closes off the SSE stream, but the
hook still fires for those prompts.
A handler reads the event’s details from JSON on its standard input. Every event
passes at least its own name and the matcher subject. Tool events add the tool
name and the tool’s input, except post_tool_use_failure, which carries the
failure that stopped the tool in place of the input. Subagent events add the
subagent type. Polytoken also sets POLYTOKEN_* environment variables for the
handler: POLYTOKEN_HOOK_EVENT, POLYTOKEN_HANDLER_NAME,
POLYTOKEN_HOOK_MATCHER_SUBJECT, POLYTOKEN_NON_INTERACTIVE, and
POLYTOKEN_GOAL_ACTIVE on every hook, and POLYTOKEN_SESSION_ID,
POLYTOKEN_PROJECT_DIR, POLYTOKEN_PROJECT_PATH, POLYTOKEN_FACET_NAME, and
POLYTOKEN_MODEL_NAME when the session, project, facet, and model are known.
POLYTOKEN_GOAL_ACTIVE is true or false depending on whether a saved-session
goal is running. POLYTOKEN_FACET_NAME is the active facet name (for example,
plan or execute), and POLYTOKEN_MODEL_NAME is the active model’s config-key
name (for example, claude-opus-4-6).
Matching
Section titled “Matching”A matcher is a glob. When a hook has one, Polytoken fires the hook only when
the glob matches the event’s subject. The subject depends on the event:
- For
pre_tool_use,post_tool_use, andpost_tool_use_failure, the subject is the tool name, so"matcher": "file_read"fires only for thefile_readtool and"matcher": "file_edit_*"fires for every edit tool whose name starts withfile_edit_. - For
subagent_startandsubagent_stop, the subject is the subagent type. - For every other event, the subject is the event name itself, so a
matcheron those events is rarely useful: leave it off and the hook fires every time.
The glob syntax is the usual wildcard style: * matches any run of characters,
? matches one character, and [a-z] matches a range.
Handlers
Section titled “Handlers”The handler object names what Polytoken runs. It has a bash key whose value is
a Bash script:
{ "bash": "jq -r .tool_name >> /tmp/tools-used.log" }Polytoken runs the script, writes the event JSON to its standard input, and sets
the POLYTOKEN_* environment variables. The script reports its decision on
standard output or through its exit code.
A handler has a short deadline to finish, so a hung handler cannot stall the agent
loop. Polytoken treats a handler that runs past the deadline as an error for that
event. For blocking events, Polytoken waits for the handler to finish or time out
before continuing, so a slow handler on a blocking event adds its runtime to the
action it gates. A slow post_clear handler, for example, delays the /clear
response until it finishes or times out.
What a handler returns
Section titled “What a handler returns”A handler reports its decision as a single JSON object on standard output, tagged
by an outcome field. Each event defines its own set of outcomes, named for what
that event does, so the outcome you return depends on the event that triggers
the hook. An outcome carries only the fields that event reads; an unknown field
is an error.
Blocking events
Section titled “Blocking events”A blocking event reads its handler’s outcome and acts on it. Every blocking event
accepts a proceed outcome; most also accept a stop outcome, and two accept a
suppress outcome that holds the action back quietly:
| Event | Outcomes | Fields you can set |
|---|---|---|
pre_tool_use | allow, deny | deny: reason. |
pre_user_prompt | accept, reject | accept: additional_context. reject: reason. |
pre_model_turn | proceed, retry | proceed: additional_context. retry: reason. |
stop | stop, continue | continue: reason. |
pre_compaction | allow, cancel, suppress | allow: prepend_to_prompt. cancel, suppress: reason. |
session_start | allow | allow: additional_context. |
post_clear | allow | allow: additional_context. |
post_compaction | allow | allow: append_to_output. |
notification | allow, suppress | suppress: reason. |
Every field the table lists is optional: a bare {"outcome": "deny"} is valid.
The stop outcome does what the event names:
denystops a tool call.rejectturns a prompt away.retrymakes Polytoken inject the reason and retry the model turn.continuekeeps the loop going instead of letting the model hand the turn back to you.cancelstops the compaction.suppressholds the action back quietly: anotificationhook uses it to drop a notification, and apre_compactionhook uses it to skip a compaction.
Once a handler returns deny, reject, retry, continue, or cancel,
matching hooks later in the order do not run for that event. An error or
suppress outcome does not have that effect, so every matching hook runs.
session_start, post_clear, and post_compaction accept only allow: their
action has already happened, so a handler can add context but cannot stop it.
The additional_context an allow, accept, or proceed adds becomes a
system-reminder the model sees. append_to_output appends text to the finished
compaction summary. prepend_to_prompt works earlier in the flow: Polytoken puts
the text at the front of the instructions it sends the model that writes the
summary, so the text guides how the model writes the summary rather than appearing
in the session itself. When several hooks supply prepend_to_prompt or
append_to_output text, the first non-empty value wins and Polytoken ignores the
rest.
The deny, retry, and continue outcomes also accept stdout and stderr
fields alongside reason. Those fields capture the output of a script that
reports its decision through an exit code rather than JSON.
Fire-and-forget events
Section titled “Fire-and-forget events”post_model_turn, post_tool_use, post_tool_use_failure, facet_switch,
subagent_start, subagent_stop, interrogative_wants_open, and
interrogative_has_closed run in the background, and Polytoken discards what
they return. A handler for one of these events returns acknowledged when the
script succeeds; the side effect the script performs is the point of the hook. A
script that exits with an error reports error, which Polytoken logs.
Reporting an error
Section titled “Reporting an error”Any handler can return { "outcome": "error", "message": "..." } to say it could
not decide; the message is the one required field in any outcome. Polytoken
records the error and does not read it as success. A hook that cannot
decide should not let a guarded action proceed, so an error on a blocking event
that gates an action stops that action: an error on pre_tool_use,
pre_model_turn, stop, or pre_compaction blocks the tool call, the turn, the
handback, or the compaction. Four blocking events instead fail
open and let their action proceed on an error: pre_user_prompt, notification,
post_compaction, and post_clear. An error on session_start also changes
nothing beyond the log: the session is already running, so the cost is the context
the hook would have added. A fire-and-forget event has no decision to fail, so
Polytoken logs an error from one of its handlers and nothing else changes.
Exit-code shorthand
Section titled “Exit-code shorthand”A bash handler can report its decision through its exit code, which is
convenient for a one-line script:
- Exit
0with no output is the event’s proceed outcome. - Exit
2is the event’s stop outcome on an event that has one, and Polytoken captures what the script printed:deny,retry, andcontinueturn the printed text into the message the model sees, whilerejectandcancelturn it into the reason. On an event with no stop outcome, exit2is an error. - Any other non-zero exit is an error, with the script’s standard error in the message.
Turning off an inherited hook
Section titled “Turning off an inherited hook”A project file can switch off a global hook by name. Add a string entry of the
hook’s name prefixed with !:
[ "!log-edits"]Polytoken drops the global hook named log-edits for this project. The name must
match an existing global hook; otherwise Polytoken rejects the file.
A small hook
Section titled “A small hook”A project hook in .polytoken/hooks.json that blocks the file_edit_search_replace
tool from editing a locked file:
[ { "name": "protect-generated-reference", "event": "pre_tool_use", "matcher": "file_edit_search_replace", "handler": { "bash": "test \"$(jq -r '.input.path // empty')\" = docs/reference/generated.md && { echo 'docs/reference/generated.md is generated; do not edit it by hand.'; exit 2; }; exit 0" } }]The hook fires before the file_edit_search_replace tool runs. The script reads
the event JSON from standard input, checks the target path, and exits 2 to deny
the call with a reason when the path is the locked file. For any other path it
exits 0 and the edit proceeds.
Loading changes
Section titled “Loading changes”Polytoken loads hooks at startup and when you reload its configuration; an edit
to hooks.json takes effect only on the next reload.