Jaiph Hooks

Workflows often need side effects — notifications, structured logging, CI integration — but that logic does not belong in .jh sources. Hooks solve this: they are optional shell commands the CLI runs at fixed points in the run lifecycle, configured in a single hooks.json file rather than scattered across workflows.

Under the hood, jaiph run follows a predictable path: prepare scripts, spawn the workflow runner (locally or in Docker), stream __JAIPH_EVENT__ JSON lines from the runner’s stderr, then print PASS/FAIL. Hooks tap into that path. The CLI parses the same stderr events that drive the progress tree and builds a JSON payload for each hook command. Hooks live entirely in the CLI (they are not executed by NodeWorkflowRuntime); channels and inbox dispatch are runtime concerns. See Architecture — Runtime vs CLI responsibilities and Architecture — Channels and hooks in context.

Hooks run only for normal jaiph run (including the jaiph <file.jh> shorthand). They are not triggered by jaiph test, jaiph init, jaiph compile, or other commands. jaiph run --raw also skips hooks (along with the banner, progress tree, and failure footer); that path exists so another process can consume stderr unchanged — for example the host CLI when Docker runs jaiph run --raw inside the container. See the --raw bullet under CLI — jaiph run.

For local runs, hooks use the same machine as the workflow. For Docker-backed runs, hook commands still execute on the host CLI process (not inside the container); see Sandboxing — Runtime behavior.

Config locations

Scope Path
Global ~/.jaiph/hooks.json
Project-local <workspace>/.jaiph/hooks.json

Both files are optional. <workspace> is resolved using the same rules as JAIPH_WORKSPACE for jaiph run: walk up from the entry .jh file’s directory, with guards for temp directories and nested sandboxes. Full rules: CLI — Environment variables.

Configuration uses per-event override precedence: if the project file lists at least one non-empty command for an event, those commands run and the global ones for that event are ignored. Lists are not merged. If neither file defines an event, nothing runs for it.

Schema

Each file must be a single JSON object at the root (not an array) mapping event names to arrays of shell commands:

{
  "workflow_start": ["echo 'run started'"],
  "workflow_end": ["curl -s -X POST https://example.com/jaiph/end -d @-"],
  "step_start": [],
  "step_end": ["jq -c . >> \"$HOME/.jaiph/step-events.jsonl\""]
}

An empty array (or omitting the key) means “no commands from this file for this event,” so resolution falls back to global hooks when the project file does not override that event (see Precedence).

Supported events

Event When it fires
workflow_start After buildScripts completes (parse, validateReferences, script extraction to scripts/) and before the runner subprocess is spawned. Does not fire if compilation fails.
workflow_end After the runner subprocess exits (any status), before the CLI prints PASS/FAIL.
step_start When the CLI observes a step-start event on the runner’s stderr stream.
step_end When the CLI observes a step-end event on that stream.

Step kinds correspond to the runtime step types: workflow, rule, script, and prompt. Step hooks are driven by the same __JAIPH_EVENT__ stderr stream as the progress tree; see CLI — Run progress and tree output.

Precedence

Resolution happens per event, independently:

There is no explicit “disable” mechanism. Omitting an event or using [] means “fall back to global.” To suppress a global hook for one project, override that event with a no-op: "workflow_end": ["true"].

Payload

Each command receives a single JSON object on stdin (UTF-8). Parse it with jq, python3 -c, or any tool you prefer. Stdin can only be read once — if your command needs the payload more than once, capture it in a variable first (see Examples).

Fields

Field Present in Description
event all Event name: workflow_start, workflow_end, step_start, or step_end.
workflow_id all Runtime run id (run_id from step events on the stderr stream). Empty on workflow_start. For workflow_end, the CLI reuses the first non-empty run_id it saw on a step event (empty if the runner never emitted one). step_start / step_end pass through the run_id from each event (usually the same value once the run is underway).
timestamp all ISO 8601 timestamp (from the CLI or runtime event).
run_path all Absolute path to the .jh file being run.
workspace all Workspace root directory (same rules as Config locations).
step_id step_* Step id used for progress and log paths. Usually the runtime’s id; if empty, the CLI synthesizes a stable legacy:… id so starts and ends match.
step_kind step_* workflow, rule, script, or prompt.
step_name step_* Step name (e.g. default, scan_passes).
status *_end Exit status: 0 = success, non-zero = failure. For workflow_end, non-zero if the subprocess exited non-zero or the CLI detected a fatal error on stderr (see CLI).
elapsed_ms *_end Milliseconds elapsed: total wall time (workflow_end) or step duration (step_end).
run_dir workflow_end Absolute path to the run’s log directory (from runner metadata). Omitted if metadata is missing.
summary_file workflow_end Absolute path to run_summary.jsonl (from runner metadata). See CLI — Run summary. Omitted if unavailable.
out_file step_end Step stdout log path, if the file was non-empty. Omitted otherwise.
err_file step_end Step stderr log path, if the file was non-empty. Omitted otherwise.

Payload by event

Example payload (step_end):

{
  "event": "step_end",
  "workflow_id": "abc-123",
  "step_id": "run:1:1",
  "step_kind": "workflow",
  "step_name": "default",
  "status": 0,
  "timestamp": "2026-03-11T12:00:00.000Z",
  "elapsed_ms": 1500,
  "run_path": "/repo/flows/ci.jh",
  "workspace": "/repo",
  "out_file": "/repo/.jaiph/runs/.../step.out",
  "err_file": "/repo/.jaiph/runs/.../step.err"
}

Behavior

Payload shapes for tooling are also declared in TypeScript as HookPayload / HookEventName in src/types.ts.

Examples

Global ~/.jaiph/hooks.json — POST the workflow-end payload to an HTTP endpoint:

{
  "workflow_end": ["curl -s -X POST https://example.com/jaiph/end -d @-"]
}

Project .jaiph/hooks.json — append a one-line JSON record per finished step, and log each workflow end under the workspace:

{
  "step_end": ["jq -c '{event,step_kind,step_name,status,elapsed_ms}' >> \"$HOME/.jaiph/step-events.jsonl\""],
  "workflow_end": ["p=$(cat); echo \"$p\" | jq -c '{event,status,run_dir,summary_file}' >> \"$(echo \"$p\" | jq -r .workspace)/.jaiph/workflow-ends.jsonl\""]
}

Note that stdin can only be read once per process. The workflow_end command stores the payload in p so it can pipe it to multiple jq invocations. The step_end command reads stdin once via a single jq call.

The step_end example writes to a fixed path under $HOME so it does not depend on where jaiph run was invoked. The workflow_end example writes relative to the project using the workspace field from the payload.

Project overrides global: If global defines workflow_end: ["global-notify.sh"] and the project defines workflow_end: ["project-notify.sh"], only project-notify.sh runs.