Skip to content

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.

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.

EndpointPOST /hooks (single event), POST /hooks/batch (buffered delivery)
Filetappass/api/routes/hooks/claude_code.py:95 (single), :297 (batch)
Supported providersclaude-code, cursor, windsurf
Event typesPreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, SessionEnd
Authtp_ developer key — same as /v1/chat/completions

tappass/api/routes/hooks/claude_code.py:37:

class HookEvent(BaseModel):
provider: str # "claude-code" | "cursor" | "windsurf"
event: dict[str, Any] # the raw hook payload

Claude 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.

EventDefault modeCan block?Why
PreToolUseenforceyes (exit 2)Last chance to stop a tool call before it fires
PostToolUseauditnoTool already ran; we record what happened
UserPromptSubmitenforceyes (exit 2)Scan prompts for injection / secrets before the agent sees them
StopauditnoCapture the final assistant text + transcript reasoning
SessionStartauditnoBookend for correlation
SessionEndauditnoSession close → finalise cost / violation roll-up

Every mode emits an AuditEvent. "Enforce" adds the governance pipeline in front of the audit.

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:

TriggerCode path
Tool denylist / allowlist mismatchclaude_code.py:210-216
Governance pipeline rejectionclaude_code.py:256-270
Prompt-scan failureclaude_code.py:277-285

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.

  • event_type=hook_unknown in audit — the payload used event.hook instead of event.hook_event_name (Claude's current field name). Fixed in commit 80bac6f; if you see it resurface, check the upstream schema.
  • tool_input arriving as a Python-repr string — dict/list payloads getting str()-ified. _safe_truncate in the same file now passes dicts/lists through; don't regress that.
  • Batch endpoint for Stop only — we buffer Stop events because the transcript can be large; one event at a time would flood the audit writer. PreToolUse must NEVER be batched — blocking decisions need to be synchronous.

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.

tappass/pipeline/hooks.py:

HookWhen it firesWhat it does
before_pipeline(ctx, steps)Once, before the step loop startsSession 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 returnsRe-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 finishesSession metrics update, decision-tree build, baseline observations for the Trust Profile

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.

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.

You want to…Reach for…
Stop a Claude Code tool call before it runsPreToolUse hook, return exit_code: 2
Record what Claude Code agents actually didPostToolUse + Stop events, audit-only
Run work before every step startsNo such hook — put it in before_pipeline
Run work once per request after all stepsafter_pipeline
React to a classification discovered mid-pipelineafter_classify
Enforce policy on the gateway path (/v1/chat/...)Not a hook — that's the pipeline itself