Hook Authoring Guide
Hooks observe and gate the agent loop. They do not rewrite prompts or silently mutate model context.
Hook points
pre-sessionpost-sessionpre-planpre-toolpost-toolpre-commiton-erroron-step-budget
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
fiJavaScript / 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
- Hooks must be deterministic.
- Hooks must not exfiltrate prompts, tool arguments, or model output.
- Hooks should fail closed when they protect secrets or destructive operations.
- Hook output is recorded in the session event log.