Lifecycle hooks - contract reference¶
This document is the single source of truth for the Bernstein lifecycle hook pipeline. It covers:
- The full event vocabulary.
- The JSON payload schema for every event.
- The stdout protocol scripts use to allow/deny/mutate/annotate.
- Hook script discovery (
.bernstein/hooks/<event>.{sh,py}). - The
bernstein hooksCLI.
If you have ported a hook script from another orchestrator and it is not running unchanged in Bernstein, the contract is the issue - file a bug.
TL;DR¶
| Step | Command |
|---|---|
| Drop a script | chmod +x .bernstein/hooks/preToolUse.sh |
| List what fires for an event | bernstein hooks list |
| Smoke-test a script | bernstein hooks dry-run preToolUse |
| Use a custom payload | bernstein hooks dry-run preToolUse --payload my.json |
| Validate all scripts | bernstein hooks check |
Event vocabulary¶
Bernstein recognises two families of events.
Cross-CLI standardised events¶
These match the de-facto names used by neighbouring orchestrators so a script written for another tool drops into .bernstein/hooks/ without modification.
| Event | Required payload keys | Optional payload keys |
|---|---|---|
sessionStart | session_id | role, prompt_template_sha, env_snapshot |
userPromptSubmitted | session_id, prompt | attached_files |
preToolUse | session_id, tool, args | blast_radius_score |
postToolUse | session_id, tool, args, result | duration_ms, cost, success |
errorOccurred | session_id, error_class, message | recovery_path |
idle | session_id, idle_duration_s | - |
sessionEnd | session_id, status | total_cost, total_tokens |
Extra keys are always allowed. Schemas are validated up-front by bernstein hooks dry-run and at dispatch time when a payload is explicitly supplied; missing required keys raise PayloadSchemaError before any script runs.
Bernstein-native events¶
The original snake_case events remain fully supported and are unchanged by issue #1323. Existing hook scripts continue to work without edits.
| Event | When it fires |
|---|---|
pre_task | Before a task transitions out of open. |
post_task | After a task reaches a terminal state. |
pre_merge / post_merge | Around integration merges. |
pre_spawn / post_spawn | Around agent session spawn. |
pre_archive / post_archive | Around task archival. |
These events accept any payload shape - no schema is enforced.
Where hooks live¶
A hook is any executable file whose path the orchestrator can reach. There are three registration channels:
- Convention-based - drop an executable at
.bernstein/hooks/<event>.{sh,py}. The filename stem is matched against the event vocabulary;preToolUse.sh,preToolUse.py, andsession_start.share all valid examples. - Config-based - declare scripts in
bernstein.yamlunder the top-levelhooks:key. Use this when you want explicit ordering or a non-default timeout. - Plugin-based - implement
@hookimplagainstLifecycleHookSpecin a pluggy plugin. Plugin names registered underhooks.<event>inbernstein.yamlare documented references only - the plugin must be loaded the usual way.
Example bernstein.yaml:
hooks:
preToolUse:
- script: scripts/policy/preflight.sh
timeout: 5
postToolUse:
- scripts/audit/log_tool_use.sh
- plugin: bernstein_plugin_jira
sessionEnd:
- scripts/cleanup.sh
JSON protocol¶
Input¶
Hooks receive a single-line JSON object on stdin. The top-level shape is the same for every event:
{
"event": "preToolUse",
"task": "T-123",
"session_id": "s-abc",
"workdir": "/path/to/worktree",
"env": {"BERNSTEIN_FOO": "bar"},
"timestamp": 1736251200.0,
"data": {
"session_id": "s-abc",
"tool": "shell.run",
"args": {"command": "ls"},
"blast_radius_score": 0
}
}
The per-event payload lives under data. Bernstein also sets the following environment variables on the subprocess:
| Variable | Description |
|---|---|
BERNSTEIN_EVENT | Event value (e.g. preToolUse). |
BERNSTEIN_TASK_ID | Task ID (when task-scoped). |
BERNSTEIN_SESSION_ID | Agent session ID. |
BERNSTEIN_WORKDIR | Working directory the hook should treat as CWD. |
BERNSTEIN_* | Any other BERNSTEIN_* env variable inherited from the parent. |
Anything else is stripped - secrets and unrelated process state do not leak into hook subprocesses.
Output¶
Hooks may emit a single-line JSON object on stdout to influence the pipeline. Stdout that is empty, plain text, or non-object JSON is treated as an implicit allow.
| Decision | Effect |
|---|---|
{"decision": "allow"} | Default. Continue the chain. |
{"decision": "deny", "reason": "<text>"} | For preToolUse, blocks the tool call and surfaces a structured audit-chain event. |
{"decision": "mutate", "data": {...}} | Replaces LifecycleContext.data for the rest of the chain. |
{"decision": "annotate", "data": {...}} | Merges keys into LifecycleContext.data without replacing it. |
Unknown decision verbs are normalised to allow, but the raw record is preserved for downstream consumers that want to inspect richer responses.
Exit codes¶
| Exit code | Meaning |
|---|---|
0 | Success. Decision parsed from stdout (if any). |
2 | Blocking error. Pipeline halts and stderr is surfaced. |
| Any other non-zero | Treated as a failure, raises HookFailure. |
A deny decision is distinct from a non-zero exit: deny is a structured refusal that the orchestrator records as an audit event, while a non-zero exit is treated as a hook bug the operator must investigate.
Example: deny shell.run for a session¶
.bernstein/hooks/preToolUse.sh:
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
tool="$(echo "$payload" | jq -r '.data.tool')"
if [[ "$tool" == "shell.run" ]]; then
echo '{"decision": "deny", "reason": "shell.run blocked by site policy"}'
exit 0
fi
echo '{"decision": "allow"}'
Smoke-test:
bernstein hooks dry-run preToolUse
# DENIED: preToolUse blocked by script:.bernstein/hooks/preToolUse.sh: shell.run blocked by site policy
Example: annotate postToolUse with billing data¶
#!/usr/bin/env python3
import json
import sys
payload = json.loads(sys.stdin.read())
duration_ms = payload["data"].get("duration_ms", 0)
print(json.dumps({
"decision": "annotate",
"data": {"billing_unit_cost": duration_ms * 0.0001},
}))
Place this at .bernstein/hooks/postToolUse.py, mark it executable, and subsequent hooks in the chain will see billing_unit_cost in their payload.
CLI reference¶
bernstein hooks list¶
Print every hook registered for every event, including convention-based scripts and plugin references.
bernstein hooks dry-run <event> [--payload <file>]¶
Fire <event> with a synthetic payload. The default payload is the documented sample for the event; pass --payload <file> to supply a JSON object instead. The payload is validated against the schema before any script runs.
Exit codes:
0- every hook in the chain returnedallow(or didn't emit a decision).1- a hook raisedHookFailureor the payload failed validation.2- a hook explicitly denied the event.
bernstein hooks check¶
Validate that every script declared in bernstein.yaml exists and is executable. Useful as a pre-merge gate so a broken hook reference never lands on main.
bernstein hooks run <event>¶
Fire <event> with an empty context. Kept for backwards compatibility; prefer dry-run for new workflows since it ships sample payloads.
Schema enforcement and forward compatibility¶
- Schemas are additive. Bernstein never strips unknown keys from
LifecycleContext.data; extra annotations propagate to every hook in the chain and to plugin@hookimplconsumers. - Missing required keys fail loudly.
bernstein hooks dry-runwill refuse to dispatch and tell you which key is missing. - The 7 cross-CLI event names are stable. New events may be added in the future; existing event names will not change.
Audit-chain integration¶
Every deny decision in the preToolUse chain produces a structured audit event (issue #1316 surface). The event payload includes:
- The event value (
preToolUse). - The denying hook's label (e.g.
script:.bernstein/hooks/preToolUse.sh). - The reason string returned by the hook.
- The original
LifecycleContext.data(withtool,args, etc.).
These events are forwarded through the standard on_audit_event pluggy hook so any SIEM sink (Splunk, Datadog, Elastic, MQTT) sees denials automatically.
Migration checklist for existing hook scripts¶
If you are porting a hook script from another CLI:
- Drop it at
.bernstein/hooks/<event>.{sh,py}andchmod +xit. - Run
bernstein hooks dry-run <event>to confirm it executes. - If the script reads its event metadata from environment variables (e.g.
CLAUDE_EVENT,OPENAI_TOOL_NAME), adapt it to read fromBERNSTEIN_*variables instead, or parse the JSON payload from stdin. - If the script writes decisions to stdout in a non-JSON format, wrap the output in the JSON envelope documented above.
That's it. The contract is intentionally narrow so the port stays mechanical.