Skip to content

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.

You define hooks in a hooks.json file. Polytoken reads two of them:

  • Global, at hooks.json in your Polytoken config directory, for hooks you want in every project.
  • Project, at .polytoken/hooks.json in 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.

A hook entry has four fields:

{
"name": "log-edits",
"event": "post_tool_use",
"matcher": "file_edit_*",
"handler": { "bash": "cat >> /tmp/polytoken-edits.log" }
}
FieldTypeRequiredMeaning
namestringyesA string identifying the hook. Used in logs and in the negation syntax below.
eventstringyesThe point in the loop that fires this hook. One of the names in Events.
matcherstringnoA glob that narrows which instances of the event fire the hook. Omit it to fire on every instance. See Matching.
handlerobjectyesThe 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.

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.

EventWaitsFires
session_startyesWhen a session begins.
pre_user_promptyesBefore Polytoken records a prompt you submit.
pre_model_turnyesBefore Polytoken calls the model for a turn.
post_model_turnnoAfter the model finishes a turn.
pre_tool_useyesBefore a tool runs.
post_tool_usenoAfter a tool succeeds.
post_tool_use_failurenoAfter a tool fails.
stopyesWhen the model would finish and Polytoken would hand the turn back to you.
pre_compactionyesBefore Polytoken compacts the session.
post_compactionyesAfter Polytoken compacts the session.
notificationyesWhen Polytoken would raise a notification.
facet_switchnoAfter the active facet changes.
subagent_startnoAfter a subagent starts.
subagent_stopnoAfter 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.

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, and post_tool_use_failure, the subject is the tool name, so "matcher": "file_read" fires only for the file_read tool and "matcher": "file_edit_*" fires for every edit tool whose name starts with file_edit_.
  • For subagent_start and subagent_stop, the subject is the subagent type.
  • For every other event, the subject is the event name itself, so a matcher on 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.

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.

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.

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:

EventOutcomesFields you can set
pre_tool_useallow, denydeny: reason.
pre_user_promptaccept, rejectaccept: additional_context. reject: reason.
pre_model_turnproceed, retryproceed: additional_context. retry: reason.
stopstop, continuecontinue: reason.
pre_compactionallow, cancel, suppressallow: prepend_to_prompt. cancel, suppress: reason.
session_startallowallow: additional_context.
post_compactionallowallow: append_to_output.
notificationallow, suppresssuppress: 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.

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.

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.

A bash handler can report its decision through its exit code, which is convenient for a one-line script:

  • Exit 0 with no output is the event’s proceed outcome.
  • Exit 2 is 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, exit 2 is an error.
  • Any other non-zero exit is an error, with the script’s standard error in the message.

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 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.

Polytoken loads hooks at startup and when you reload its configuration; an edit to hooks.json takes effect only on the next reload.