Hook Development Guide¶
A guide to programmatic hook development
This document covers what you need in order to write and register hooks in Java against the
aimon-core framework. For declarative hooks configured through hooks.json or SKILL.md
frontmatter, see the Hook Config Guide.
Table of contents¶
- Overview
- Event types
- Implementing a hook
- Registering a hook
- Context objects
- Returning a HookResult
- Execution policy
- A complete example
Overview¶
A hook is an extension point invoked at a specific moment during agent execution. Through hooks you can observe what the agent does (auditing, metrics) or block and transform tool calls.
Core principles¶
| Principle | Description |
|---|---|
| Single Responsibility | One hook performs one clearly defined job |
| Thread-safe | The same instance may run concurrently across several agents and threads |
| Never throw | An escaping exception may be converted into a block, depending on the policy |
| Blocking works in four chains only | block() on every other event is silently ignored (below) |
| Stateless | Keeps no state between executions (push it into an external store if you need it) |
Package structure¶
at.aimon.core.hook/
├── HookRegistry.java # Hook registry interface
├── DefaultHookRegistry.java # Default registry implementation
├── HookEventType.java # Typed event token (13 constants)
├── HookExecutionManager.java # Hook execution manager
├── HookFeedback.java # feedback → model-message renderer
├── event/ # Per-event hook interfaces + contexts
│ ├── PreToolHook.java / PreToolContext.java
│ ├── PostToolHook.java / PostToolContext.java
│ ├── OnStartHook.java / OnStopHook.java
│ ├── PermissionRequestHook.java / PermissionDeniedHook.java
│ ├── SubagentStartHook.java / SubagentStopHook.java
│ ├── OnSessionStartHook.java / OnSessionEndHook.java
│ ├── PreCompactHook.java / PostCompactHook.java
│ └── OnConfigReloadHook.java
├── execution/ # Execution model
│ ├── ExecutionHook.java # Supertype of every hook
│ ├── HookContext.java # Supertype of every context
│ ├── HookResult.java # Decision × FlowControl result
│ ├── HookExecutionPolicy.java # timeout / parallel / dedup policy
│ └── DefaultHookExecutor.java
└── rewake/ # Asynchronous re-invocation (async rewake)
Event types¶
HookEventType<H> is a typed token — the constant carries the hook interface type, so
registry.register(HookEventType.PRE_TOOL, hook) accepts a PreToolHook and nothing else.
There are 13 in total:
| Constant | Hook interface | Fires at | Can block |
|---|---|---|---|
PERMISSION_REQUEST |
PermissionRequestHook |
Tool permission decision | ✅ deny |
PRE_TOOL |
PreToolHook |
Immediately before tool execution | ✅ block |
POST_TOOL |
PostToolHook |
Immediately after tool execution | ❌ |
PERMISSION_DENIED |
PermissionDeniedHook |
Post-processing after a permission denial | ❌ |
ON_START |
OnStartHook |
Turn start | ✅ block |
ON_STOP |
OnStopHook |
Turn end | ❌ |
ON_SESSION_START |
OnSessionStartHook |
Conversation start | ❌ |
ON_SESSION_END |
OnSessionEndHook |
Conversation end | ❌ |
SUBAGENT_START |
SubagentStartHook |
Subagent start | ❌ |
SUBAGENT_STOP |
SubagentStopHook |
Subagent end | ❌ |
PRE_COMPACT |
PreCompactHook |
Immediately before context compaction | ✅ block |
POST_COMPACT |
PostCompactHook |
Immediately after context compaction | ❌ |
ON_CONFIG_RELOAD |
OnConfigReloadHook |
Immediately after a configuration hot reload | ❌ |
⚠️ Returning
HookResult.block()from an event whose "can block" column says ❌ does nothing at all. The call sites that actually consumeBLOCKare these four:
PRE_TOOL— skips the tool call and hands the reason to the model as the tool resultPERMISSION_REQUEST— denies before dispatchON_START— aborts the turn withExecutionBlockedByHookExceptionPRE_COMPACT— skips AUTO compaction / reports the reason for MANUAL compactionEvery other event is advisory. Do not design an audit or notification hook as a gate.
Declarative hooks (
hooks.json/ SKILL.md) can refuse in the same four chains only, and a refusal is expressed as exit 2 from the shell handler. The declarative veto onON_STARTwas added recently — before that, anonStartshell hook exiting 2 had no effect whatsoever.
Adding a new event means adding all of: the hook interface, the context type, the
HookEventType constant, the HookExecutionManager method, and the firing site. A constant
with no firing site is dead configuration.
Implementing a hook¶
Every hook is a subinterface of ExecutionHook<C extends HookContext> with exactly one abstract
method, HookResult execute(C context), so it can be written as a lambda.
PreToolHook (can block / can transform the input)¶
// Security hook — blocks dangerous commands
PreToolHook securityHook = context -> {
if ("Bash".equals(context.getCurrentToolUse().getName())) {
String command = (String) context.getCurrentToolUse().getInput().get("command");
if (command != null && command.contains("rm -rf")) {
return HookResult.block("Dangerous command blocked: " + command);
}
}
return HookResult.success();
};
// Advisory hook — a word to the model without blocking
PreToolHook adviceHook = context -> HookResult
.withFeedback("Use pnpm rather than npm in this repository.");
// Input-transforming hook — propagates to later hooks and to the actual tool call
PreToolHook normalizeHook = context -> {
ToolInput current = ToolInput.of(context.getCurrentToolUse().getInput());
if (!current.has("timeout")) {
Map<String, Object> patched = new LinkedHashMap<>(current.toMap());
patched.put("timeout", 30_000);
return HookResult.withUpdatedInput(ToolInput.of(patched));
}
return HookResult.success();
};
PostToolHook (observation / output transformation)¶
PostToolHook auditHook = context -> {
auditLogger.log(context.getInvokerName(), context.getToolUse().getName(),
context.getCurrentToolUseResult().isError() ? "FAILURE" : "SUCCESS");
return HookResult.success();
};
// Output masking — changes the result the model sees
PostToolHook redactHook = context -> {
ToolResult out = context.currentOutput();
if (out.getContent().contains("BEGIN PRIVATE KEY")) {
return HookResult.withUpdatedOutput(ToolResult.success("[redacted: private key]"));
}
return HookResult.success();
};
Lifecycle hooks¶
OnStartHook initHook = context -> {
log.info("Agent '{}' started at {}", context.getInvokerName(), context.getTimestamp());
return HookResult.success();
};
OnStopHook summaryHook = context -> {
notificationService.sendSummary(context.getFinalAnswer());
return HookResult.success();
};
Hooks that do long work¶
A hook that takes longer than the default hook timeout (30 seconds) must declare its own budget. Without a declaration the executor's outer net cuts it off first, and the perfectly good result the hook was about to produce is discarded.
class ExternalPolicyHook implements PreToolHook {
@Override
public Optional<Duration> getExecutionBudget() {
return Optional.of(Duration.ofSeconds(90)); // the net widens to 90s + grace
}
@Override
public HookResult execute(PreToolContext context) {
return policyClient.evaluate(context.getCurrentToolUse()); // has its own deadline
}
}
A declared budget is a floor, not an override — it only widens the net, never narrows it.
The ceiling is MAX_DECLARED_BUDGET, 10 minutes; anything larger is clamped to 10 minutes with
a WARN log.
getHookId()¶
Override getHookId() when several instances of one class may be registered. Async rewake
routing and configuration hot-reload cancellation both use this value as a key. The id must be
derived from content and stable across reloads (see DeclarativeHookId for declarative
hooks).
Registering a hook¶
Using the HookRegistry¶
HookRegistry exposes five generic methods rather than per-event ones.
HookRegistry registry = new DefaultHookRegistry();
registry.register(HookEventType.ON_START, initHook);
registry.register(HookEventType.PRE_TOOL, securityHook);
registry.register(HookEventType.PRE_TOOL, adviceHook); // several may be registered
registry.register(HookEventType.POST_TOOL, auditHook);
registry.register(HookEventType.ON_STOP, summaryHook);
List<PreToolHook> preTool = registry.getHooks(HookEventType.PRE_TOOL); // type-safe
registry.unregister(HookEventType.PRE_TOOL, adviceHook);
A mismatched type does not compile — register(HookEventType.PRE_TOOL, auditHook) tries to put
a PostToolHook where a PreToolHook belongs, which is a compile error.
Execution order¶
When several hooks are registered for the same event they run in registration order.
// Execution order: securityHook -> loggingHook -> rateLimitHook
registry.register(HookEventType.PRE_TOOL, securityHook);
registry.register(HookEventType.PRE_TOOL, loggingHook);
registry.register(HookEventType.PRE_TOOL, rateLimitHook);
updatedInput / updatedOutput accumulate in that order and are reflected in the next hook's
getCurrentToolUse() / currentOutput().
Context objects¶
Each hook receives the context object that matches its firing point. Every context implements
HookContext.
Common fields (HookContext)¶
| Accessor | Type | Description |
|---|---|---|
getInvokerType() |
InvokerType |
Invoker type (MAIN_AGENT, SUBAGENT, …) |
getInvokerName() |
String |
Invoker name |
getHookRegistry() |
HookRegistry |
The hook registry |
getEnvironment() |
Environment |
Environment configuration |
getTimestamp() |
Instant |
Timestamp |
getExecutionAttributes() |
Map<String, Object> |
Supplementary execution information |
PreToolContext¶
| Accessor | Type | Description |
|---|---|---|
getOriginalToolUse() |
ToolUse |
The original, before any hook touched it |
getCurrentToolUse() |
ToolUse |
The current value with earlier hooks' updatedInput applied — use this to see the tool that will actually run |
getIterationCount() |
int |
The current ReAct loop iteration count |
PostToolContext¶
| Accessor | Type | Description |
|---|---|---|
getToolUse() |
ToolUse |
The tool that ran |
getOriginalToolUseResult() |
ToolUseResult |
The original result |
getCurrentToolUseResult() |
ToolUseResult |
The result with earlier hooks' updatedOutput applied |
originalOutput() / currentOutput() |
ToolResult |
ToolResult views of the same values |
getIterationCount() |
int |
The current ReAct loop iteration count |
OnStopContext¶
| Accessor | Type | Description |
|---|---|---|
isSuccess() |
boolean |
Whether the turn succeeded |
getFinalAnswer() |
String |
The final answer |
getMetadata() |
ExecutionMetadata |
Execution metadata such as the iteration count |
The contexts for the remaining events follow the same rule — common fields plus event-specific fields, all immutable.
Returning a HookResult¶
HookResult is two independent axes, not a single enum.
Decision—ALLOW/ASK/DENY(only the permission chain interpretsASK)FlowControl—CONTINUE/BLOCK
Results from several hooks are merged by HookResult.merge(...) under the precedence
deny > ask > allow and block > continue.
Factories¶
HookResult.success(); // ALLOW + CONTINUE
HookResult.allow(); // the explicit spelling of success()
HookResult.withFeedback("..."); // ALLOW + CONTINUE + a message for the model
HookResult.block("reason"); // BLOCK — meaningful only in the four blockable chains
HookResult.deny("reason"); // DENY + BLOCK (permission chain)
HookResult.ask("reason"); // ASK — asks the user to confirm
HookResult.withUpdatedInput(toolInput); // preTool input transformation
HookResult.withUpdatedOutput(toolResult); // postTool output transformation
HookResult.asyncRewake(spec); // a request to be woken again later
HookResult.builder()...build(); // set several axes at once
The reason passed to
deny(reason)/block(reason)is stored in the feedback field. The feedback of a blocked result is its refusal reason, so do not surface it a second time as advisory feedback.
How feedback reaches the model¶
withFeedback(msg) is the only way to speak to the model. Do not assemble the text yourself —
render it through HookFeedback.
Rendering is only half the contract — the firing site has to read the returned result for the feedback to reach the model. Today there are exactly three such paths; every other firing site simply discards the result.
| Chain | Where the feedback goes | Rendered in |
|---|---|---|
PERMISSION_REQUEST / PRE_TOOL / POST_TOOL |
Appended to that tool's result as <system-reminder key="hook-feedback"> |
SingleToolInvoker |
ON_START |
Added to the conversation as a user-role message (same wrapper) | OrcaAgentExecutor, DefaultSubagentExecutor |
PRE_COMPACT |
Not a message — merged into the summarisation prompt as a custom instruction | DefaultCompactionEngine |
Feedback from the tool-related chains cannot become a separate user message because no user turn
may sit between a tool_use and its tool_result.
⚠️ The other 8 events silently drop feedback — the firing sites for
PERMISSION_DENIED,ON_STOP,ON_SESSION_START,ON_SESSION_END,SUBAGENT_START,SUBAGENT_STOP,POST_COMPACTandON_CONFIG_RELOADnever read the return value; they call for side effects only. "Lifecycle chains append a user message" applies toON_STARTalone. Opening a new path is a feature, not a bug fix — it needs a call site that decides where the block goes.The feedback of a blocked result is its refusal reason, so
HookFeedback.collectAdvisory(...)filters it out: the blocking site already renders that reason as an error.
Execution policy¶
HookExecutionPolicy decides how a chain runs.
| Item | Default | Description |
|---|---|---|
timeout |
30 seconds | The outer safety net for a single hook |
timeoutBehavior |
FAIL_OPEN |
On timeout, pass (FAIL_OPEN) or block (FAIL_CLOSED) |
executionMode |
SEQUENTIAL |
Parallel execution is opt-in |
stopOnBlocked |
Policy-dependent | Short-circuits the remaining hooks once a blocking result appears |
dedupKeyExtractor |
None | Removes duplicate hooks sharing a key |
timeoutFor(hook)is the time actually applied. If a hook declares a budget throughgetExecutionBudget()that is at least the policy timeout, the net widens to it (plusDECLARED_BUDGET_GRACE, 5 seconds); a shorter declaration is ignored. The comparison is<, so a budget exactly equal to the policy timeout still gets the grace — becauseShellAction.DEFAULT_TIMEOUTandHookExecutionPolicy.DEFAULT_TIMEOUTare both 30 seconds, every declarative shell hook that omitstimeoutMslands in exactly this case. Excluding equality would make the net race the hook's own deadline under the default settings.- A declared budget is clamped by
MAX_DECLARED_BUDGET(10 minutes). It comes from configuration (timeoutMsinhooks.jsonor frontmatter) and is therefore unvalidated; without a ceiling one hook could hold a turn indefinitely, and values nearLong.MAX_VALUEoverflow the executor's nanosecond conversion. Anything larger is truncated to 10 minutes with a WARN log. - In parallel mode a timeout bounds the wait; it does not discard work that already finished. Results are always reassembled in registration order.
stopOnBlockedis meaningful only underSEQUENTIAL. UnderPARALLELan already submitted hook cannot be cancelled, so it is a no-op and every result is returned as is.- A wait aborted by an interrupt is BLOCKED regardless of the policy. Once the thread driving
the turn is interrupted,
future.getthrows without waiting, so routing that exception throughonExceptionwould let every hook in the chain pass fail-open without having run. No verdict means no permission to proceed, so the executor answers BLOCKED (the same reasonTimeoutBehavior.FAIL_CLOSEDexists). A hook that already finished is unaffected — its completed result comes back as is.
Reattaching an async rewake¶
As with getExecutionBudget(), rewake has different reattachment rules per trigger kind
(DeclarativeRewake):
delay/event— the envelope fires exactly once, so the spec must be reattached on every fire for the next link to exist. The chain is bounded byRewakeSpec#getMaxAttempts().cron— the envelope is registered as the scheduler's native cron trigger and repeats by itself, so it is not reattached. Reattaching would add an extra chain envelope per fire, branching the live envelope count by 2× per fire (roughly2^(maxAttempts-1)).- Exceptions are mapped through
onException. Under afailClosedStopOnBlockedpolicy a bug in the hook turns into a block, so it is safer to catch the exception inside the hook and turn it into an explicit result.
A complete example¶
A security and audit hook system¶
package at.aimon.example.hook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import at.aimon.core.hook.DefaultHookRegistry;
import at.aimon.core.hook.HookEventType;
import at.aimon.core.hook.HookRegistry;
import at.aimon.core.hook.execution.HookResult;
/**
* An example hook system set up for security and auditing.
*/
public class SecurityAuditHookSetup {
private static final Logger log = LoggerFactory.getLogger(SecurityAuditHookSetup.class);
public HookRegistry createSecurityAuditRegistry() {
HookRegistry registry = new DefaultHookRegistry();
// Turn start — session initialisation
registry.register(HookEventType.ON_START, context -> {
log.info("[AUDIT] Turn started: agent={}, type={}, time={}", context.getInvokerName(),
context.getInvokerType(), context.getTimestamp());
return HookResult.success();
});
// PreTool — security checks (this chain really does block)
registry.register(HookEventType.PRE_TOOL, context -> {
String toolName = context.getCurrentToolUse().getName();
var input = context.getCurrentToolUse().getInput();
if ("Bash".equals(toolName)) {
String command = (String) input.get("command");
if (isDangerousCommand(command)) {
log.warn("[SECURITY] Blocked dangerous command: {}", command);
return HookResult.block("Security policy violation: dangerous command blocked");
}
}
if ("Read".equals(toolName) || "Write".equals(toolName)) {
String path = (String) input.get("file_path");
if (isSensitivePath(path)) {
log.warn("[SECURITY] Blocked access to sensitive path: {}", path);
return HookResult.block("Security policy violation: access to sensitive path blocked");
}
}
return HookResult.success();
});
// PreTool — audit logging (a second hook on the same event; runs in registration order)
registry.register(HookEventType.PRE_TOOL, context -> {
log.info("[AUDIT] Tool invocation: tool={}, iteration={}, input={}",
context.getCurrentToolUse().getName(), context.getIterationCount(),
context.getCurrentToolUse().getInput());
return HookResult.success();
});
// PostTool — result auditing (cannot block; observation only)
registry.register(HookEventType.POST_TOOL, context -> {
log.info("[AUDIT] Tool completed: tool={}, error={}", context.getToolUse().getName(),
context.getCurrentToolUseResult().isError());
return HookResult.success();
});
// Turn end — summary
registry.register(HookEventType.ON_STOP, context -> {
log.info("[AUDIT] Turn ended: agent={}, success={}", context.getInvokerName(), context.isSuccess());
return HookResult.success();
});
return registry;
}
private boolean isDangerousCommand(String command) {
if (command == null) {
return false;
}
return command.contains("rm -rf") || command.contains("sudo") || command.contains("chmod 777")
|| command.contains("> /dev/");
}
private boolean isSensitivePath(String path) {
if (path == null) {
return false;
}
return path.contains("/etc/passwd") || path.contains("/etc/shadow") || path.contains(".ssh/")
|| path.contains(".env");
}
}
Related documents¶
- Hook Config Guide — declarative hooks in
hooks.json/ SKILL.md - HookEventType.java
- HookResult.java
- HookRegistry.java
- Hook system upgrade design — the Phase 1–5 implementation record
- Tool development guide