Hooks
Two different concepts share the word "hook" in our codebase. They don't overlap — the first lives on the control-plane HTTP surface, the second lives inside the pipeline runner. Keep them separate in your head.
A. Agent-tool hooks (external)
Section titled “A. Agent-tool hooks (external)”What they are. An integration point for agent runtimes that emit lifecycle events — Claude Code, Cursor, Windsurf. The agent sends us HTTP events when a tool is about to run, when a prompt is submitted, when a session starts/ends. We decide whether to audit, block, or pass through.
HTTP surface
Section titled “HTTP surface”| Endpoint | POST /hooks (single event), POST /hooks/batch (buffered delivery) |
| File | tappass/api/routes/hooks/claude_code.py:95 (single), :297 (batch) |
| Supported providers | claude-code, cursor, windsurf |
| Event types | PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, SessionEnd |
| Auth | tp_ developer key — same as /v1/chat/completions |
Payload (HookEvent)
Section titled “Payload (HookEvent)”tappass/api/routes/hooks/claude_code.py:37:
class HookEvent(BaseModel): provider: str # "claude-code" | "cursor" | "windsurf" event: dict[str, Any] # the raw hook payloadClaude Code sometimes posts its native flat shape (fields at the
top level, no nesting). The _accept_flat_shape validator
transparently wraps it into {provider, event} so the backend sees
one canonical form. Don't rely on the wire shape in step code —
read from event inside the handler.
Processing modes per event type
Section titled “Processing modes per event type”| Event | Default mode | Can block? | Why |
|---|---|---|---|
PreToolUse | enforce | yes (exit 2) | Last chance to stop a tool call before it fires |
PostToolUse | audit | no | Tool already ran; we record what happened |
UserPromptSubmit | enforce | yes (exit 2) | Scan prompts for injection / secrets before the agent sees them |
Stop | audit | no | Capture the final assistant text + transcript reasoning |
SessionStart | audit | no | Bookend for correlation |
SessionEnd | audit | no | Session close → finalise cost / violation roll-up |
Every mode emits an AuditEvent. "Enforce" adds the governance
pipeline in front of the audit.
The block contract
Section titled “The block contract”When the backend wants the agent runtime to stop, it replies:
{ "status": "blocked", "reason": "tool_input contained pattern: AWS_SECRET_ACCESS_KEY", "blocked_by": "detect_secrets", "exit_code": 2}Claude Code's hook runner reads exit_code: 2 and aborts the tool
call. Any other exit code (or a 200 with status != "blocked") is
a pass-through.
Triggers for blocking:
| Trigger | Code path |
|---|---|
| Tool denylist / allowlist mismatch | claude_code.py:210-216 |
| Governance pipeline rejection | claude_code.py:256-270 |
| Prompt-scan failure | claude_code.py:277-285 |
Why this matters
Section titled “Why this matters”The hook surface is how we enforce governance on agents that don't
route their LLM calls through our gateway. Claude Code on a
Max subscription bypasses /v1/chat/completions entirely — the only
control point we have is the hook. PostToolUse gives visibility;
PreToolUse + UserPromptSubmit give enforcement.
Common wiring gotchas
Section titled “Common wiring gotchas”event_type=hook_unknownin audit — the payload usedevent.hookinstead ofevent.hook_event_name(Claude's current field name). Fixed in commit80bac6f; if you see it resurface, check the upstream schema.tool_inputarriving as a Python-repr string — dict/list payloads getting str()-ified._safe_truncatein the same file now passes dicts/lists through; don't regress that.- Batch endpoint for
Stoponly — we buffer Stop events because the transcript can be large; one event at a time would flood the audit writer.PreToolUsemust NEVER be batched — blocking decisions need to be synchronous.
B. Pipeline phase hooks (internal)
Section titled “B. Pipeline phase hooks (internal)”What they are. Three callback points around the 49-step pipeline loop. Not pre/post-step — pre/post-phase. Used for work that only needs to run once per request, or for re-querying policy after new information becomes available.
The three phases
Section titled “The three phases”tappass/pipeline/hooks.py:
| Hook | When it fires | What it does |
|---|---|---|
before_pipeline(ctx, steps) | Once, before the step loop starts | Session tracking, tool-integrity check, pre-scan (one-pass text scanner cached on ctx so every detection step can read findings without re-scanning), barrier snapshot |
after_classify(ctx, remaining) | Once, immediately after classify_data returns | Re-queries OPA so a newly-discovered classification (e.g. CONFIDENTIAL → RESTRICTED) can retroactively escalate earlier continue decisions to block |
after_pipeline(ctx, result) | Once, after the step loop finishes | Session metrics update, decision-tree build, baseline observations for the Trust Profile |
What's explicitly not there
Section titled “What's explicitly not there”There are no per-step pre/post hooks. The runner
(pipeline/runner.py:39-295) is a linear loop that calls
step.execute(ctx) and records the result — no before/after
wrappers per step.
This is deliberate: cross-step state goes on PipelineContext; if
you're tempted to insert a hook between two steps, the right move is
almost always to add a field to the context or a new step that
expresses the transformation.
When to add a new phase hook
Section titled “When to add a new phase hook”Almost never. Adding a phase hook is architectural — it changes the contract of every pipeline run. Prefer a new step unless:
- The work is genuinely phase-scoped (e.g. "after classify" is a phase because classification changes the interpretation of earlier detections).
- It must run exactly once regardless of the step set (e.g. opening an audit transaction).
- It can't be expressed as a step for ordering reasons.
If it can be a step, it should be a step.
Quick cross-reference
Section titled “Quick cross-reference”| You want to… | Reach for… |
|---|---|
| Stop a Claude Code tool call before it runs | PreToolUse hook, return exit_code: 2 |
| Record what Claude Code agents actually did | PostToolUse + Stop events, audit-only |
| Run work before every step starts | No such hook — put it in before_pipeline |
| Run work once per request after all steps | after_pipeline |
| React to a classification discovered mid-pipeline | after_classify |
Enforce policy on the gateway path (/v1/chat/...) | Not a hook — that's the pipeline itself |
Also see
Section titled “Also see”- Domain objects —
Decision/Mandate/Detectionall flow through the hooks above. - Pipeline step anatomy — the 49 steps the phase hooks wrap around.
- Customer data export — hook
events live in
AuditEvent, which gets exported under Art. 15.