Hook any production agent. Ship extensibility without a fork.
Project description
Ship agents customers can extend without touching your code.
Named hook points · Customer-owned logic · OTel spans + metrics · Timeout-safe · Append-only audit
pip install agenthooks-py
The Problem
You build a production AI agent. A customer deploys it. They need:
- Inject their own context before every LLM call
- Enforce their approval workflows before write operations
- Log to their own audit system
- Apply their own rate limits and compliance rules
- Block certain operations based on their internal policies
Today: They fork the agent, modify source code, and you lose control of the release cycle.
With agenthooks: You declare named hook points inside your agent. Customers register their logic against those points. No fork. No PR. Nothing breaks if their hook fails.
Install
pip install agenthooks-py # zero-dependency core
pip install agenthooks-py[pydantic] # + type-validated contexts
pip install agenthooks-py[otel] # + OpenTelemetry API (spans + metrics)
pip install agenthooks-py[all] # everything
30-second demo
from agenthooks import HookAgent, hookpoint, HookRegistry, HookContext
# Agent author declares hook points — once, in their agent code.
class SearchAgent(HookAgent):
before_search = hookpoint("before_search")
async def search(self, query: str) -> dict:
ctx = HookContext.new(session_id="s1", tenant_id="acme", query=query)
async with self.before_search.run(ctx) as ctx:
return {"query": ctx.query, "filters": ctx.metadata.get("filters", {})}
# Customer registers their own logic — zero source changes to the agent.
registry = HookRegistry()
@registry.implement("before_search")
async def inject_region_filter(ctx: HookContext) -> HookContext:
return ctx.enrich("filters", {"region": "EU", "language": "en"})
# At deploy time, customer attaches their registry.
agent = SearchAgent(registries=[registry])
result = await agent.search("quarterly report")
# {'query': 'quarterly report', 'filters': {'region': 'EU', 'language': 'en'}}
Customer Freedom
Every deployment is different. agenthooks lets customers hook their logic at any point in the agent pipeline — without a fork:
# Customer A: approval gate
@acme_registry.implement("before_execute", filter={"tenant": "ACME"})
@block_if(lambda ctx: ctx.tool_name == "delete_all", reason="Requires VP approval")
@inject(approved_by="manager@acme.com")
async def acme_approval(ctx): return ctx
# Customer B: compliance enrichment
@globex_registry.implement("before_execute", filter={"tenant": "GLOBEX"})
@inject(compliance_tier="SOC2", data_residency="US")
async def globex_compliance(ctx): return ctx
# Customer C: rate limiting
@initech_registry.implement("before_execute", filter={"tenant": "INITECH"})
@rate_limit(per="tenant", limit=500, window_s=60)
async def initech_rate_limit(ctx): return ctx
# One agent binary. Three independent customer extensions. No source changes.
agent = MyAgent(registries=[acme_registry, globex_registry, initech_registry])
Each customer's hooks only fire for their tenant. They can't see or affect each other's logic.
Production Reliability
Customer hook code cannot crash the agent. Every hook runs under a timeout. Failures degrade gracefully.
@registry.implement("before_call",
timeout_ms=200, # hard timeout — hook gets 200ms
fallback=True, # on timeout/error: degrade silently, continue
order=10, # execution order across multiple hooks
)
async def external_enrichment(ctx): ...
# If this times out → agent continues with whatever context was built before it.
# If this throws → agent continues. Error is recorded in audit trail + OTel span.
OpenTelemetry — Production Observability
Every hook execution is automatically traced and metered. No instrumentation code required.
# Wire up the OTel SDK once at startup (pip install agenthooks[otel])
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
# Every hook now appears as a child span in Jaeger / Tempo / Datadog APM.
# No changes to agent code or hook code required.
Spans emitted per hook execution:
| Attribute | Value |
|---|---|
hook.name |
hookpoint name (before_call) |
hook.impl |
implementation function name |
hook.tenant_id |
tenant from context |
hook.status |
ok | timeout | error | blocked | skip |
hook.duration_ms |
wall-clock latency |
Metrics emitted automatically:
| Metric | Type | Dimensions |
|---|---|---|
agenthooks.hook.executions |
Counter | hook.name, hook.impl, hook.status |
agenthooks.hook.duration_ms |
Histogram | hook.name, hook.impl |
agenthooks.hook.errors |
Counter | hook.name, hook.impl |
agenthooks.hook.timeouts |
Counter | hook.name |
agenthooks.hook.blocked |
Counter | hook.name, hook.impl |
When the OTel SDK is not installed, a zero-allocation in-process fallback is used. Metrics are still readable in tests.
Audit Trail
Every hook execution is written to an append-only JSONL audit log. This cannot be disabled — it is a security invariant.
{"ts": 1750000000.0, "hook.name": "before_call", "hook.impl": "acme_approval", "hook.status": "ok", "hook.duration_ms": 12.3, "hook.tenant_id": "ACME", "trace_id": "abc123", "session_id": "sess-1"}
{"ts": 1750000001.0, "hook.name": "before_execute", "hook.impl": "blocker", "hook.status": "blocked", "hook.error": "Requires VP approval", "hook.tenant_id": "ACME", "trace_id": "abc124"}
from agenthooks import AuditTrail, set_default_audit
# Custom path (default: ~/.agenthooks/audit.jsonl)
set_default_audit(AuditTrail(path="/var/log/myagent/hooks.jsonl"))
Pattern Decorators
Zero-boilerplate hooks for the most common enterprise patterns:
from agenthooks import inject, block_if, redact, rate_limit, require_tenant, retry
# Inject static or dynamic context
@inject(plant="1000", fiscal_year=lambda ctx: erp.get_fy(ctx.tenant_id))
async def my_hook(ctx): return ctx
# Block based on a condition
@block_if(lambda ctx: not authz.allowed(ctx.tenant_id), reason="Not authorised")
async def my_hook(ctx): return ctx
# Redact sensitive fields in audit logs
@redact("api_key", "bearer_token", "password")
async def my_hook(ctx): return ctx
# Rate limit by tenant or session
@rate_limit(per="tenant", limit=1000, window_s=60, on_exceeded="block")
async def my_hook(ctx): return ctx
# Allow-list tenants
@require_tenant("ACME", "GLOBEX")
async def my_hook(ctx): return ctx
# Retry on transient failures
@retry(max_attempts=3, backoff_ms=100)
async def my_hook(ctx): return await external_service.enrich(ctx)
# Compose freely — decorators stack bottom-up
@inject(env="production")
@block_if(lambda ctx: not ctx.tenant_id, reason="No tenant")
@redact("api_key")
@rate_limit(per="tenant", limit=500, window_s=60)
async def full_pipeline_hook(ctx): return ctx
How It Works
Agent declares hook points Customer registers implementations
───────────────────────── ─────────────────────────────────
class MyAgent(HookAgent): @registry.implement("before_call",
before_call = hookpoint( filter={"tenant": "ACME"},
"before_call", order=10,
mode="multi", timeout_ms=200,
) fallback=True)
async def my_impl(ctx): ...
At runtime, for each hook point:
1. Collect all registered impls matching the current context filters
2. Sort by order
3. Execute sequentially (or in parallel if parallel=True)
└─ Each impl runs under its timeout_ms budget
└─ Timeout → degrade (log + metric + audit), continue
└─ Error → degrade (log + metric + audit), continue
└─ HookBlocked → propagate to agent (controlled stop)
└─ HookSkip → short-circuit remaining impls
4. OTel span + metric recorded for every execution
5. Audit trail entry written (always)
6. Yield enriched context to agent body
Repo Structure
agenthooks/
├── src/agenthooks/
│ ├── __init__.py ← all public exports
│ ├── core/
│ │ ├── context.py ← HookContext (immutable, sealed fields)
│ │ ├── registry.py ← HookRegistry + @implement decorator
│ │ ├── hookpoint.py ← hookpoint() descriptor + executor
│ │ ├── exceptions.py ← HookBlocked, HookSkip, HookTimeout, ...
│ │ └── contract.py ← semver range contract validation
│ ├── executor/
│ │ ├── sequential.py ← SequentialExecutor
│ │ └── parallel.py ← ParallelExecutor
│ ├── agent/
│ │ ├── base.py ← HookAgent base class
│ │ └── wrapper.py ← HookWrapper (wraps any callable)
│ ├── store/
│ │ └── memory.py ← InMemoryStore
│ ├── security/
│ │ └── guards.py ← injection_scan()
│ ├── observability.py ← OTel spans, metrics, structured logging
│ ├── audit.py ← AuditTrail (append-only JSONL)
│ └── patterns.py ← inject, block_if, redact, rate_limit, ...
├── tests/ ← 91 tests
├── examples/
│ ├── 01_basic_hooks.py
│ ├── 02_customer_extensibility.py
│ ├── 03_multi_tenant_isolation.py
│ ├── 04_resilience.py
│ └── 05_opentelemetry.py
└── docs/ ← architecture, security, flow docs
Security
- Sealed fields —
session_id,tenant_id,trace_id,span_id,turn,timestampare read-only for hook implementations. Any attempt to write them raisesHookSecurityError. - Injection scanning —
injection_scan()detects prompt injection patterns in hook-modified queries before they reach the LLM. - Redaction —
ctx.redact("field")marks fields so audit logs and OTel exporters surface them as[REDACTED]. - Tenant isolation — filter conditions are evaluated by the executor, not by hook code. A hook cannot see or affect another tenant's execution.
- Audit invariant — the audit trail cannot be disabled. Every hook execution (including failures) is recorded.
Development Setup
git clone https://github.com/naveenkumarbaskaran/agenthooks
cd agenthooks
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest tests/ # 91 passed
Roadmap
SqliteStore— durable registry across restartsHttpRegistry— remote hook implementations over HTTPStreamingContext— delta streaming through hook pipeline- OTel SDK integration tests with real Jaeger
- CLI:
agenthooks audit— verify audit trail integrity - Circuit breaker per hook impl
Apache 2.0 License · Docs · Issues
Built by Naveen Kumar Baskaran
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agenthooks_py-0.1.0.tar.gz.
File metadata
- Download URL: agenthooks_py-0.1.0.tar.gz
- Upload date:
- Size: 41.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
df891ec2c814f77996b6548b75115b02c51206e3aa9247678d6e6aed28a690a6
|
|
| MD5 |
c77869ce6042a2ca187678b0bc4e01c8
|
|
| BLAKE2b-256 |
e6232978cad9f5c74ada04a5a22bf3eb03d52030f91bc7bd857f2b7ff32ee66d
|
File details
Details for the file agenthooks_py-0.1.0-py3-none-any.whl.
File metadata
- Download URL: agenthooks_py-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7a3b2b13759f6ebbd31f917c62bc0f116bd76995a82a32faa22046d9b57f276
|
|
| MD5 |
78527579dd77389a1f15f97dc18ec9b7
|
|
| BLAKE2b-256 |
432ff922e161affedb9ea048c97b4f1927d5ed051473aa5e59414f74d7916740
|