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 your Polytoken config directory, 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. Used in logs and in the negation syntax below. |
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, or an unknown handler key as a load error. A
malformed hook fails loudly rather than silently never firing.
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. |
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. |
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 its 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, and
POLYTOKEN_NON_INTERACTIVE on every hook, and POLYTOKEN_SESSION_ID,
POLYTOKEN_PROJECT_DIR, and POLYTOKEN_PROJECT_PATH when the session and project
are known.
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 shell style: * matches within a segment, **
matches across segments, ? 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, as the sections below describe.
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.
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 the hook is
attached to. 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_compaction | allow | allow: append_to_output. |
notification | allow, suppress | suppress: reason. |
The stop outcome does what the event names. deny stops a tool call. reject
turns a prompt away. retry sends the model turn back with the reason injected.
continue keeps the loop going instead of letting the model hand the turn back to
you. cancel stops the compaction. suppress holds the action back quietly: a
notification hook uses it to drop a notification, and a pre_compaction hook uses
it to skip a compaction. Its reason is optional.
session_start 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 or accept adds becomes a system-reminder the
model sees. prepend_to_prompt and append_to_output add text before and after
the compaction summary. The deny, reject, retry, and continue outcomes also
accept stdout and stderr fields alongside reason, which 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, and subagent_stop 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. Polytoken records the error and does not read it as success. On a
blocking event that gates an action, that error stops the 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, on the principle that a hook that cannot
decide should not let a guarded action proceed. Three blocking events instead fail
open and let their action proceed on an error: pre_user_prompt, notification,
and post_compaction. A fire-and-forget event has no decision to fail, so an error
from one of its handlers is logged 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 so the model sees it. 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 edits to a locked file:
[ { "name": "protect-changelog", "event": "pre_tool_use", "matcher": "file_edit_search_replace", "handler": { "bash": "test \"$(jq -r '.input.path // empty')\" = CHANGELOG.md && { echo 'CHANGELOG.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.