Hook contract
fastpace ships with hooks that mediate AI tool calls: JSON on stdin, JSON
decision on stdout, exit 0. The scripts are written once
against a canonical event model and generated into each assistant's native
bundle by fastpace plugin build --target
claude-code|codex|gemini-cli|all — a Claude Code plugin
(PreToolUse/PostToolUse/UserPromptSubmit),
a Codex plugin (same events, .codex-plugin manifest), and a
Gemini CLI extension (BeforeTool/AfterTool, with
matcher translation like Bash → ShellTool). One codebase, three
vendors, all 15 hooks native on each — that's how fastpace stays
runtime-agnostic. fastpace plugin coverage prints the
hook→native-event matrix.
Hook input
Every hook reads stdin once. The JSON body has these well-known fields:
{
"hook_event_name": "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Mutation",
"tool_name": "Bash" | "Read" | "Write" | "Edit" | "Grep" | "Glob" |
"mcp__github__list_issues" | ...,
"tool_input": { ... tool-specific ... },
"tool_output": { ... post-call only ... },
"metadata": {
"agent_id": "reviewer",
"model": "claude-opus-4.7",
"runtime": "bedrock-claude-opus-4.7",
"endpoint": "https://...",
"training_opt_out": true,
...
},
"session_id": "...",
"cwd": "/path/to/repo"
}
Hook output
{
"decision": "allow" | "block" | "modify",
"reason": "human-readable string",
"summary": { ... structured signal recorded in the audit chain ... },
"coach": "optional structured coaching message"
}
Decision values
- allow — Tool call proceeds unchanged.
- block — Tool call is refused;
reasonis shown to the user. The runtime should not retry. - modify — UserPromptSubmit hooks only. The hook returns a modified prompt in
summary.modified_prompt.
Shipped hooks
| Hook | Phase | Purpose |
|---|---|---|
audit-logger | PostToolUse | F0.2 — appends every tool call to the hash-chained signed audit log. |
prompt-redactor | UserPromptSubmit | F0.5 — strips secrets + PII before the prompt reaches the runtime. Custom patterns via config. |
agent-scope-guard | PreToolUse | F0.4 — enforces allowed_tools / paths / commands / max_files per agent. |
runtime-guard | PreToolUse | F1.11 — blocks calls to runtimes not on the approved list. Fires gate.failed webhook on block. |
mcp-scope-mediator | PreToolUse | F1.7 — checks allowed_servers + per-server allowed_actions for any mcp__* tool call. |
run-manifest-writer | PostToolUse | F1.1 — writes a signed run-manifest receipt for every tool call. |
correction-detector | PostToolUse Edit/Write | F2.1 — uses git blame to detect human edits to AI-authored lines; classifies severity; appends to corrections.log. |
sast-required | PreToolUse Bash | F2.13 — fires only on git push (or opt-in patterns). Runs every configured SAST/lint adapter; blocks if any finding meets block_on. |
Writing your own hook
Drop a Node script into .claude/hooks/<name>.js. The
runtime will pipe stdin to it and read stdout. Keep it under 50ms on
the happy path — see the bench harness for measurement.
#!/usr/bin/env node
'use strict';
const chunks = [];
process.stdin.on('data', (c) => chunks.push(c));
process.stdin.on('end', () => {
let input = {};
try { input = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
catch { emit({ decision: 'allow', reason: 'parse_error' }); return; }
// your decision logic here
if (input.tool_name === 'Bash' && /rm -rf \/.*/.test(input.tool_input.command)) {
return emit({ decision: 'block', reason: 'dangerous_command' });
}
emit({ decision: 'allow' });
});
function emit(o) { process.stdout.write(JSON.stringify(o) + '\n'); process.exit(0); }
Failing safely
Wrap your main path in try/catch. Return { decision: 'allow' }
on unexpected errors — the alternative is the developer being unable
to work, which produces worse outcomes than a missed signal. High-risk
hooks (runtime-guard, agent-scope-guard) fail closed by explicit design —
if the policy can't be evaluated, the call is blocked.
Firing webhooks from a hook
Use the bundled helper at assets/hooks/_webhook-fire.js.
It deferred-imports @fastpace-ai/fp's webhook module and
fires fully async, so the hook's allow/block decision is never delayed:
require('./_webhook-fire').fireBackground(repo, 'gate.failed', { reason: '...' });
See F3.10 webhook event bus for the seven-event taxonomy.
Privacy
Your hook can see prompt + response text in the input. Do NOT log either to disk. The shipped hooks compute sha256 digests of prompt/response bytes (when surfaced) and persist only the digests — never plaintext. This is a load-bearing invariant for the open audit schema; honor it in any hook you add.