Claude Code hooks: how to intercept every tool call before it runs

Claude Code hooks: how to intercept every tool call before it runs

One of the most powerful — and least documented — features revealed in the Claude Code source is the hooks system. You can intercept every single tool call Claude makes, before it executes.

This means you can:

  • Auto-approve certain commands (no more hitting Enter 40 times)
  • Block dangerous operations entirely
  • Log every file Claude touches
  • Inject context before tool execution

Here’s how it works.

The hooks directory

Create a .claude/hooks/ directory in your project:

mkdir -p .claude/hooks

Hooks are shell scripts that Claude executes at specific lifecycle points.

PreToolUse hook

This runs before any tool call. The tool name and arguments are passed as environment variables:

# .claude/hooks/PreToolUse.sh
#!/bin/bash

# Auto-approve file reads — stop asking me every time
if [ "$TOOL_NAME" = "Read" ]; then
  exit 0  # 0 = approve
fi

# Block rm -rf entirely
if [ "$TOOL_NAME" = "Bash" ] && echo "$TOOL_INPUT" | grep -q 'rm -rf'; then
  echo "Blocked: rm -rf is not allowed" >&2
  exit 1  # 1 = reject
fi

# Everything else: default behavior
exit 0

Make it executable:

chmod +x .claude/hooks/PreToolUse.sh

PostToolUse hook

This runs after a tool executes. Use it for logging or side effects:

# .claude/hooks/PostToolUse.sh
#!/bin/bash

# Log every file write to a change log
if [ "$TOOL_NAME" = "Write" ]; then
  echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WROTE: $TOOL_INPUT_PATH" >> .claude/changes.log
fi

# Log every bash command Claude runs
if [ "$TOOL_NAME" = "Bash" ]; then
  echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) BASH: $TOOL_INPUT" >> .claude/commands.log
fi

exit 0

Available hook types

From the source, there are four hook points:

Hook When it fires
PreToolUse Before any tool call
PostToolUse After any tool call
Notification When Claude sends a notification
Stop When Claude finishes a task

Available environment variables

All hooks receive these env vars:

$TOOL_NAME        # "Read", "Write", "Bash", "Edit", etc.
$TOOL_INPUT       # Full JSON of the tool arguments
$TOOL_INPUT_PATH  # For file tools: the file path
$TOOL_OUTPUT      # PostToolUse only: what the tool returned
$SESSION_ID       # Current Claude Code session

Real-world use case: auto-approve safe operations

The most common use case is eliminating repetitive permission prompts:

# .claude/hooks/PreToolUse.sh
#!/bin/bash

# Auto-approve all reads
if [ "$TOOL_NAME" = "Read" ]; then exit 0; fi

# Auto-approve writes to test/temp directories
if [ "$TOOL_NAME" = "Write" ]; then
  if echo "$TOOL_INPUT_PATH" | grep -qE '^(tests?/|tmp/|/tmp/)'; then
    exit 0
  fi
fi

# Auto-approve git status/log/diff (read-only git)
if [ "$TOOL_NAME" = "Bash" ]; then
  if echo "$TOOL_INPUT" | grep -qE '^git (status|log|diff|show)'; then
    exit 0
  fi
fi

# Default: require manual approval
exit 2  # 2 = ask human

Use case: Stop hook for task completion

# .claude/hooks/Stop.sh
#!/bin/bash

# Notify when Claude finishes
echo "✅ Claude Code task complete" | notify-send "Claude Code" 2>/dev/null || true

# Run your test suite automatically after every Claude session
if [ -f package.json ]; then
  npm test --silent 2>&1 | tail -5
fi

exit 0

Register hooks in settings.json

You can also register hooks in .claude/settings.json instead of the hooks directory:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/PreToolUse.sh"
          }
        ]
      }
    ]
  }
}

The matcher field filters by tool name — you can have different hooks for different tools.

Combine with CLAUDE.md for full control

Hooks handle the mechanical side. CLAUDE.md handles the behavioral side. Together:

# CLAUDE.md

## Tool behavior
- You do not need to ask permission for file reads
- You do not need to ask permission for writes to tests/ or tmp/
- Always run the test suite after making changes
- Never run rm -rf (it is blocked at the hook level)

Now Claude knows it can proceed on reads/writes, and the hook enforces the rm -rf block even if Claude forgets.

Complete starter hooks setup

Copy this into .claude/hooks/PreToolUse.sh:

#!/bin/bash
# Claude Code PreToolUse hook — safe auto-approvals

TOOL=$TOOL_NAME
INPUT=$TOOL_INPUT
PATH_=$TOOL_INPUT_PATH

# === ALWAYS APPROVE ===
[[ "$TOOL" == "Read" ]] && exit 0
[[ "$TOOL" == "LS" ]] && exit 0
[[ "$TOOL" == "Glob" ]] && exit 0
[[ "$TOOL" == "Grep" ]] && exit 0

# === ALWAYS BLOCK ===
if [[ "$TOOL" == "Bash" ]]; then
  echo "$INPUT" | grep -qE 'rm -rf|DROP TABLE|DELETE FROM' && {
    echo "Blocked dangerous command" >&2
    exit 1
  }
fi

# === DEFAULT ===
exit 0  # approve everything else

The hooks system turns Claude Code from a supervised assistant into a trusted autonomous agent — you define the safety boundaries once, and it runs within them without interruption.

If you’re running Claude on a flat-rate API (not pay-per-token), this becomes even more valuable — you can let it run long autonomous sessions without worrying about runaway costs.

I use SimplyLouie as my ANTHROPIC_BASE_URL for exactly this: $2/month flat rate, no token anxiety, hooks handling the safety layer.

Drop your hook configs in the comments — curious what permission rules others are running.

AURORA Commerce Pro: adding Sanity support without touching the storefront

When I published my first article about AURORA Commerce, a former colleague left a comment that stuck with me:

“The idea is great, but your repo-as-database approach assumes a technical operator. What about non-technical clients who need a visual CMS interface?”

He was right. And it was exactly the limitation I hadn’t addressed publicly.

So I fixed it. Here’s how.

The constraint

The original AURORA Commerce storefront reads products from YAML files via Nuxt Content:

const { data: products } = await useAsyncData('products', () =>
  queryCollection('products')
    .where('active', '=', true)
    .all()
)

This works beautifully for a technical operator. For a client who needs to update a product description on a Tuesday afternoon without touching a terminal – it doesn’t.

The naive fix would be to hardcode Sanity queries throughout the storefront pages. But that means the storefront becomes coupled to a specific CMS. Swap providers and you’re touching every page.

I wanted something cleaner.

The adapter pattern

The idea is simple: introduce a single resolver between the API routes and the data source. Storefront pages don’t query products directly – they call API routes. API routes call the adapter. The adapter knows where data comes from. Nothing else does.

Storefront pages
      ↓
API routes (/api/products, /api/products/[slug])
      ↓
getCatalogAdapter(event)
      ↓
NuxtContentAdapter | SanityAdapter | YourCustomAdapter
      ↓
CatalogProduct (normalized)
      ↓
mapProductForApi(product, locale)
      ↓
ProductApiItem (locale-aware response)

The core files:

server/utils/catalog/
  types.ts         # CatalogProduct, ProductApiItem, CatalogAdapter
  map.ts           # mapProductForApi + resolveLocale
  adapters/
    index.ts       # Resolver — reads CATALOG_PROVIDER env var
    content.ts     # Nuxt Content (YAML) implementation
    sanity.ts      # Sanity implementation

The contract

Two interfaces define the whole system.

CatalogAdapter is the contract every provider must implement:

// server/utils/catalog/types.ts
export interface CatalogAdapter {
  listProducts: (event: any) => Promise<CatalogProduct[]>
  getProductBySlug: (event: any, slug: string) => Promise<CatalogProduct | null>
}

CatalogProduct is the normalized internal shape – same fields, same types, regardless of the source. The storefront never sees raw Sanity or YAML data.

ProductApiItem is what the API routes actually return – locale-aware, with priceCents already computed:

export interface ProductApiItem {
  id: number
  name: string        // resolved from title / titleFr
  slug: string
  description: string // resolved from description / descriptionFr
  priceCents: number  // Math.round(price * 100) — euros in YAML, cents to the client
  imageUrl: string
  imageUrls: string[]
  sizes: string[]
  // ... rest of the fields
}

That priceCents conversion happens in map.ts, not in the checkout route. One place, no surprises.

The resolver

Switching providers is an environment variable:

# Use Nuxt Content YAML (default)
CATALOG_PROVIDER=content

# Use Sanity
CATALOG_PROVIDER=sanity
SANITY_PROJECT_ID=your_project_id
SANITY_DATASET=production
SANITY_API_VERSION=2025-01-01
SANITY_TOKEN=optional_read_token

The resolver in adapters/index.ts reads CATALOG_PROVIDER and returns the right adapter. API routes call it once:

// server/api/products.get.ts
export default defineEventHandler(async (event) => {
  const adapter = getCatalogAdapter(event)
  const locale = resolveLocale(event) // reads ?locale= query param
  const products = await adapter.listProducts(event)
  return products.map((p) => mapProductForApi(p, locale))
})

resolveLocale reads the locale query param and returns 'fr' | 'en'. The locale resolution stays server-side – mapProductForApi picks the right field (titleFr vs title, descriptionFr vs description, etc.) before sending the response.

No if/else scattered across the codebase. No Sanity imports in storefront pages. The checkout route is unchanged.

The Sanity adapter

The Sanity adapter is a set of pure functions – no class, no instance state:

// server/utils/catalog/adapters/sanity.ts
export const sanityAdapter: CatalogAdapter = {
  async listProducts() {
    const client = createSanityClient()
    const raw = await client.fetch(`*[_type == "product"]`)
    return raw.map(normalizeSanityProduct)
  },

  async getProductBySlug(_, slug) {
    const client = createSanityClient()
    const raw = await client.fetch(
      `*[_type == "product" && (slug.current == $slug || slug == $slug)][0]`,
      { slug }
    )
    return raw ? normalizeSanityProduct(raw) : null
  }
}

The slug query handles both Sanity’s native slug.current shape and plain string slugs – useful when migrating data from YAML.

Normalizing images

Sanity images can come in three different shapes depending on how the schema is set up:

const normalizeImages = (value: unknown): string[] => {
  if (!Array.isArray(value)) return []

  return value
    .map((image) => {
      // Plain URL string
      if (typeof image === 'string') return image

      // Sanity asset reference with resolved URL
      if (image?.asset?.url) return String(image.asset.url)

      // Direct URL field
      if (image?.url) return String(image.url)

      return ''
    })
    .filter(Boolean)
}

If your Sanity schema uses asset references without URL projection, add url to your GROQ query. The adapter handles the rest.

Full product normalization

const normalizeSanityProduct = (entry: any): CatalogProduct => ({
  productId: Number(entry.productId || 0),
  title: String(entry.title || ''),
  titleFr: optionalString(entry.titleFr),
  slug: normalizeSlug(entry.slug),
  price: Number(entry.price || 0),
  category: String(entry.category || 'tailoring') as CatalogProduct['category'],
  images: normalizeImages(entry.images),
  sizes: toStringArray(entry.sizes),
  sizeChart: Array.isArray(entry.sizeChart) ? entry.sizeChart : [],
  reviews: Array.isArray(entry.reviews) ? entry.reviews : [],
  // bilingual fields follow the same optionalString pattern
})

If Sanity changes its schema, only this function needs updating. The storefront, the cart, and the Stripe checkout don’t know.

One more thing: image resilience

Sanity image URLs can occasionally fail – CDN misses, unpublished assets. The product page now handles this gracefully:

// app/pages/produit/[slug].vue
const failedPrimaryImages = ref<string[]>([])

const primaryImageSrc = computed(() => {
  if (selectedImage.value) return selectedImage.value
  return product.value?.imageUrls.find(
    (img) => !failedPrimaryImages.value.includes(img)
  ) || product.value?.imageUrl || ''
})

const handlePrimaryImageError = () => {
  const current = primaryImageSrc.value
  if (current) failedPrimaryImages.value.push(current)
  const next = product.value?.imageUrls.find(
    (img) => !failedPrimaryImages.value.includes(img)
  )
  selectedImage.value = next || ''
}

Failed images are tracked in a ref. The computed picks the next available one automatically. No broken image placeholder, no flash – just a silent fallback.

Adding your own adapter

The architecture is open by design. Adding Contentful, Hygraph, or a custom REST API is four steps:

  1. Create server/utils/catalog/adapters/your-cms.ts
  2. Export a CatalogAdapter object with listProducts and getProductBySlug
  3. Normalize your CMS payload into CatalogProduct
  4. Register your provider in adapters/index.ts
  5. Set CATALOG_PROVIDER=your-cms

The storefront pages, the cart, and the Stripe checkout don’t change. That’s the point.

What this unlocks

The original Starter was honest about its constraint: YAML works for a technical operator, not for a client who needs a visual interface.

The Pro tier addresses that directly. Same storefront, same Stripe integration, same bilingual setup – but the catalog source becomes a configuration choice, not an architectural constraint.

→ Live demo
→ AURORA Commerce on Gumroad – Starter €29 / Pro €59

Thanks to Robin for the feedback that drove this iteration.

Built with Nuxt 4, Vue 3, Nuxt Content v3, Sanity, Pinia, Tailwind CSS, Stripe, and Bun.

ESLint v10: Flat Config Completion and JSX Tracking

ESLint version 10 has removed the legacy eslintrc configuration system, finalizing a long transition to flat config. The update enhances developer experience, especially for plugin authors and monorepo teams, by changing configuration file location and improving JSX reference tracking. Node.js support has been tightened, and new assertion options have been added to the RuleTester API.

By Daniel Curtis

A technical deep-dive into building APEX: an autonomous AI operations system on OpenClaw

The Premise
What if an AI system could market itself, track its own costs, learn from its engagement data, and sell products — all running autonomously on a cheap VPS?
That’s what I built with APEX. It’s been running for a week. Here are the real numbers, the technical decisions, and what I got wrong.

The Stack
VPS: DigitalOcean Basic ($48/month) — Ubuntu 24.04
Agent framework: OpenClaw (open source)
LLM: Anthropic Claude Sonnet 4.6 via API
Web search: Gemini provider (free tier)
Memory: SQLite with Gemini embeddings (3072 dimensions)
Social: X API (pay-per-use tier) with OAuth 1.0a
Payments: Stripe
Monitoring: Discord webhooks (5 channels)
Total daily cost: $2.12

The Architecture
APEX runs 7 autonomous cron jobs daily. Each job is an isolated OpenClaw session with a specific mission:
Time Job Purpose Model
6 AM research-scan Web news scan Haiku
8 AM engage-mentions Reply to X mentions Sonnet
10 AM daily-post Original tweet Sonnet
12 PM daily-tweets 2 tweets (hot take + question) Sonnet
4 PM engage-afternoon Engagement + build-in-public Sonnet
8 PM reddit-and-costs Reddit drafts + cost check Sonnet
11 PM daily-pnl P&L summary + memory update Sonnet
The system also runs a weekly thread every Monday — a 5-7 tweet thread on the best-performing topic of the week.

The Cost Optimization Journey
This is where things get interesting. My first version burned $7.60/day in LLM costs alone. After a week of optimization, I got it to $0.24/day — a 97% reduction.
Problem 1: Bootstrap Bloat
OpenClaw loads workspace files (SOUL.md, AGENTS.md, USER.md, etc.) on every API call. These files define the agent’s identity, rules, and context. My initial setup had ~12KB of bootstrap files.
Every API call. 12KB. That adds up fast.
Fix: Ruthlessly compressed every bootstrap file. SOUL.md went from a detailed personality essay to a tight 2,655-byte operational identity. AGENTS.md became 840 bytes. Total bootstrap: 2,335 bytes (80% reduction).
The key insight: the agent doesn’t need to know everything on every call. It needs to know who it is and what it’s doing right now. Put the rest in searchable memory.
Problem 2: Invisible Reasoning Tokens
Sonnet’s extended thinking feature generates chain-of-thought tokens you never see but still pay for. On cron jobs that just need to execute a task, this is waste.
Fix: --thinking off on every cron job. Saves 30-50% per session.
Problem 3: Context Accumulation
Shared sessions between cron jobs meant conversation history piled up. Each subsequent job in a session started with more tokens already consumed.
Fix: Isolated sessions per job. Each cron runs in its own clean session. No token inheritance.
Problem 4: Wrong Model for the Job
Running Sonnet for a simple web search scan is like using a sports car for grocery runs.
Fix: Haiku for scanning and simple tasks (~10x cheaper), Sonnet only for jobs that need quality output.
Problem 5: No Budget Guardrails
A single runaway job could blow the daily budget.
Fix: cost-control.json with per-module daily caps. The system checks these before executing.

{
  "system_daily_cap": 3.00,
  "system_monthly_cap": 60.00,
  "modules": {
    "content_pipeline": { "daily_cap": 0.80 },
    "social_engagement": { "daily_cap": 0.50 },
    "business_intel": { "daily_cap": 0.10 }
  }
}

The Memory Architecture
This is the part I’m most proud of. APEX has a 5-layer memory system:
Bootstrap files — loaded every API call, kept under 3KB
Daily logs — each cron job appends structured results to memory/YYYY-MM-DD.md
MEMORY.md — curated long-term insights, self-updated by the daily P&L job
Semantic search — Gemini embeddings indexed in SQLite (18+ chunks)
Pre-compaction flush — saves context before sessions compact (prevents memory loss)
The daily P&L job acts as the “memory curator” — it reads the day’s logs, extracts key insights, and updates MEMORY.md. Over time, the system builds a growing knowledge base of what works and what doesn’t.

What I Got Wrong

  1. Spending 4 Days Building, 1 Day Distributing
    Classic builder mistake. The system worked beautifully by Day 3. But with 3 followers on X, nobody saw it. Should have been 50/50.
  2. Broadcasting Instead of Engaging
    Original tweets to 3 followers = shouting into void. The X algorithm in 2026 rewards replies 150x more than likes. I needed strategic replies to larger accounts from day one.
  3. Optimizing Costs Before Revenue
    I spent hours getting LLM costs from $7.60 to $0.24. That felt productive. But $7.36/day in savings is irrelevant when revenue is $0. Distribution should have been the priority.
  4. Underestimating X API Limitations
    The pay-per-use API tier blocks cold replies (403 error). Quote tweets work as a workaround, but direct replies to non-followers aren’t possible programmatically on this tier.

Key Technical Lessons
Dollar signs in shell commands get interpolated — always escape them in xpost commands
Cron $(date) evaluates at creation time, not run time — tell the agent to determine the date itself
Anthropic API has intermittent overload errors — don’t build retry logic, let the next cron cycle handle it
X suppresses tweets with links — 30-50% reach reduction. Put links in replies, not the main tweet.
Memory search with Gemini embeddings is free and surprisingly effective for retrieval
Current Status
Revenue: $0 (products live on Stripe at $49 and $99)
Daily burn: $2.12
Tweets posted: 30+
Cron jobs: 7 running autonomously
Followers: growing slowly
The system works. The product exists. The gap is distribution — getting the right people to see it. That’s what Week 2 is about.

What’s Next
Strategic reply cron jobs (4x/day targeting big accounts)
Email capture funnel (free resource → nurture → paid product)
Automated product delivery via Stripe webhooks
Content SEO (you’re reading the first piece)
ClawHub publication (when GitHub account is 14 days old)

If you’re building something similar on OpenClaw, I’d love to hear about your cost optimization approaches. The ecosystem is moving fast and there’s a lot to learn from each other.

APEX is an autonomous AI operations system built on OpenClaw. Products at apex-landing-neon.vercel.app. Follow the build on X: @intoapex

Why We Built an API for Spanish Fiscal ID Validation Instead of Just Implementing It

A few months ago I was integrating fiscal identifier validation into a project. I googled it, found a 30-line JavaScript function, copied it, tested it with four cases, and it worked. I dropped it into the codebase and forgot about it.

Three months later, a user emailed us: their CIF wasn’t validating. It was a perfectly valid CIF.

That’s when I understood there’s a difference between implementing validation and maintaining it correctly.

The problem isn’t that it’s hard

Validating a NIF seems straightforward: 8 digits, one letter, modulo 23. Any developer can implement it in ten minutes.

The problems appear when you scratch the surface:

NIF: the basic algorithm works, but there are special formats — K, L and M NIFs for specific cases — that most implementations simply ignore.

NIE: starts with X, Y or Z, then follows the NIF algorithm with substitution. Most regex you’ll find online accept 7 or 8 digits interchangeably, when the correct format depends on the prefix.

CIF: this is where almost everything breaks. The control character can be a letter or a digit depending on the entity type (first letter of the CIF). Most implementations accept either for all types — which is incorrect. Additionally, when (10 - sum % 10) % 10 === 0, the control character should be J in certain cases — an edge case that produces silent bugs that are extremely hard to track down.

IBAN: the MOD-97 algorithm requires arithmetic with very large numbers. In JavaScript you need BigInt, and in environments where it’s unavailable or poorly implemented, it fails without a visible error.

None of these bugs surface in development. They surface in production, with real identifiers from real users.

The silent bug is the worst kind of bug

When a validation fails visibly — throws an exception, returns an error — you catch it and fix it. The real problem with fiscal validation bugs is that they accept invalid identifiers without warning.

A user enters a CIF with an incorrect control digit. Your code says “valid”. The data goes into the database. Months later, when you try to use that data for a fiscal operation, the problem surfaces — but now you have no idea where it came from.

The maintenance nobody budgets for

Even if you implement everything correctly today, there’s another problem: the rules can change.

Spain has updated fiscal identifier formats in the past. NIE formats have evolved. When that happens, someone has to update the code — and that someone might not be the person who wrote it, or be on the same team, or remember why each decision was made.

With an API, that’s the API maintainer’s problem. You consume the endpoint, they keep the algorithms up to date.

What we built

We built Valix — a REST API that validates NIF, NIE, CIF and IBAN using the official Spanish algorithms, with automatic type detection and a structured JSON response.

import requests

response = requests.post(
    "https://api.getvalix.io/v1/validate/trial",
    json={
        "items": [
            {"value": "12345678Z", "type": "AUTO"},
            {"value": "X1234567L", "type": "AUTO"},
            {"value": "A12345674", "type": "AUTO"},
            {"value": "ES9121000418450200051332", "type": "AUTO"}
        ]
    }
)

result = response.json()
for item in result["results"]:
    status = "valid" if item["valid"] else f"invalid: {', '.join(item['errors'])}"
    print(f"{item['value']} → {item['detected_type']} — {status}")

Output:

12345678Z → NIF — valid
X1234567L → NIE — valid
A12345674 → CIF — valid
ES9121000418450200051332 → IBAN — valid

The trial endpoint requires no registration or API key — 50 validations per day per IP so you can test it before committing to anything.

When does it make sense to implement it yourself?

Being honest: if your validation volume is very low, you’re always validating the same identifier type, and you have exhaustive tests with real-world cases, implementing it internally can make sense.

But if you’re validating identifiers from production users, mixing types (NIF + NIE + CIF + IBAN), or simply don’t want to spend time maintaining this — an API is the more sensible choice.

Resources

Disclaimer: I’m the author of Valix.