docs/hook-authoring.md

Hook Authoring Guide

Hooks observe and gate the agent loop. They do not rewrite prompts or silently mutate model context.

Hook points

A hook either allows (does nothing) or blocks (denies with a reason). There is no "rewrite the response" envelope — post-tool hooks may transform a tool result through an explicit API, but allow/deny is the core contract.

Shell hook contract

Picasso writes the hook payload as one JSON object on stdin. The script's exit code decides the outcome: exit 0 to allow, exit non-zero to block. To attach a human-readable reason, print a JSON line {"deny": true, "reason": "…"} (optionally with "details") — the last JSON line found on stdout or stderr wins.

#!/usr/bin/env bash
set -euo pipefail

# The hook payload (hook name, tool, args, …) arrives as JSON on stdin.
payload="$(cat)"

# Block a dangerous command; allow everything else by exiting 0.
if echo "$payload" | jq -e '(.args.command // "") | test("rm -rf /")' >/dev/null; then
  echo '{"deny": true, "reason": "refusing rm -rf /"}'
  exit 1
fi

JavaScript / TypeScript hook contract

A JS/TS hook module exports a default (or named handler) function that receives a HookContext and returns void/Promise<void>. Return normally to allow; call ctx.deny(reason, details?) to block (it throws to halt the handler).

export default function hook(ctx) {
  // ctx.hook          -> the hook point: "pre-tool", "post-tool", "pre-plan", …
  // ctx.workspaceRoot -> absolute workspace path
  // ctx.payload       -> the full JSON payload for this hook point
  // pre-tool / post-tool also expose: ctx.tool, ctx.args
  // post-tool also exposes:           ctx.result, ctx.transformResult(fn)
  if (ctx.hook === "pre-tool" && ctx.tool === "bash") {
    ctx.deny("bash is disabled in this workspace");
  }
}

Five reference examples

1. Pre-tool linter

Blocks tool calls that touch generated files.

export default function hook(ctx) {
  if (ctx.hook !== "pre-tool") return;
  if (JSON.stringify(ctx.args ?? {}).includes("src/generated")) {
    ctx.deny("generated files must be regenerated, not hand-edited");
  }
}

2. Post-tool redactor

Strips secrets out of a tool result before the agent sees it, using the post-tool transformResult API.

export default async function hook(ctx) {
  if (ctx.hook !== "post-tool" || ctx.tool !== "read_file") return;
  await ctx.transformResult((result) =>
    typeof result === "string" ? result.replace(/sk-[A-Za-z0-9]+/g, "[redacted]") : result,
  );
}

3. Pre-tool commit signer

Requires signed commits.

export default function hook(ctx) {
  if (ctx.hook !== "pre-tool" || ctx.tool !== "bash") return;
  const command = String(ctx.args?.command ?? "");
  if (command.includes("git commit") && !command.includes("-S")) {
    ctx.deny("release commits must be signed (git commit -S)");
  }
}

4. On-error observer

Observes provider failures without blocking — returning normally lets the loop continue into its fallback chain.

export default function hook(ctx) {
  if (ctx.hook !== "on-error") return;
  // Inspect ctx.payload for the error; observe only — do not call ctx.deny().
}

5. On-step-budget guard

Halts a run when the step budget is exhausted.

export default function hook(ctx) {
  if (ctx.hook !== "on-step-budget") return;
  // ctx.payload carries the budget figures; call ctx.deny(...) to stop the run,
  // or return to let it continue.
  ctx.deny("step budget exhausted");
}

Safety rules