跳到主要内容

Interception Hooks (block / modify / route-to-sandbox + observe events)

ai4j covers every Claude-Code-equivalent hook event: interception (can veto/rewrite) for tool calls and prompts, plus observe side-effect hooks for turn/compact/session boundaries. Two control-flow interfaces plus the existing observe-only lifecycle hooks.

  • ToolInterceptorbeforeToolCall (Claude Code "PreToolUse": block / modify / route to sandbox) and afterToolCall ("PostToolUse": block the result).
  • PromptInterceptor — before the user's input reaches the model ("UserPromptSubmit": block / modify the prompt).
  • AgentLifecycleHook — observe-only side-effects for Stop / PreCompact / SessionStart / SessionEnd (now directly registerable via AgentBuilder.lifecycleHook(...)).

This is the layer library users need to build policy, safety, or prompt-shaping into their own agent systems. The routeTo decision leverages ai4j's first-class Sandbox SPI (Daytona/E2B).

One entry point, every event, IDE-discoverable, compile-time typed. AgentHooks composes your lambdas into the runtime's interceptor slots — no need to remember which interface goes where:

Agent agent = Agents.react()
.anthropicMessages(key, baseUrl).model("glm-5.1")
.toolExecutor(executor)
.hooks(h -> h
.preToolUse((call, ctx) -> isDangerous(call) ? ToolCallDecision.block("no") : ToolCallDecision.allow())
.postToolUse((call, out, ctx) -> leaksSecret(out) ? ToolCallDecision.block("secret") : ToolCallDecision.allow())
.userPromptSubmit((input, ctx) -> looksLikeInjection(input) ? PromptDecision.block("injection") : PromptDecision.allow())
.stop(ev -> metrics.turnEnd())
.sessionStart(ev -> log.info("session start")))
.build();
facade methodeventdecision power
preToolUsePreToolUseallow / block / modify / routeTo
postToolUsePostToolUseallow / block (the result)
userPromptSubmitUserPromptSubmitallow / block / modify (the prompt)
beforeModelRequestbefore model callmodify the full AgentPrompt (system, items, temperature, tools)
stop / preCompact / sessionStart / sessionEndobserveside-effect only

Semantics: first non-allow decision wins (pre/post/prompt); observe handlers all run. Pre and Post compose into one ToolInterceptor so you can register both. The sections below cover the underlying interfaces and decisions in depth — use them directly if you prefer.

beforeModelRequest

Modify the full AgentPrompt right before the model call — change system prompt, inject context, override temperature, swap tools. This is the deepest interception point (pi's context + before_provider_request combined).

.hooks(h -> h.beforeModelRequest((prompt, ctx) ->
prompt.toBuilder().temperature(0.0).build())) // force deterministic

The four decisions

public interface ToolInterceptor {
ToolCallDecision beforeToolCall(AgentToolCall call, AgentContext context);
}
DecisionWhat happensEquivalent
allow()proceed
block(reason)veto; the reason is fed back to the model as the tool result so it can adjustClaude Code PreToolUse exit-code-2
modify(newCall)rewrite the call (name/arguments), execute the rewritten oneClaude Code JSON input-modify
routeTo(spec, command)run the command in a sandbox (Daytona/E2B) instead of locally; output fed backpi redirect-to-sandbox — but ai4j has a real Sandbox SPI

Register it on the agent builder:

Agent agent = Agents.react()
.anthropicMessages(key, baseUrl).model("glm-5.1")
.toolExecutor(executor)
.toolInterceptor((call, ctx) -> {
if (isDestructive(call)) {
return ToolCallDecision.block("destructive command blocked");
}
return ToolCallDecision.allow();
})
.build();

Example: block dangerous commands

.toolInterceptor((call, ctx) -> {
String cmd = extractCommand(call);
if (cmd != null && cmd.contains("rm -rf")) {
return ToolCallDecision.block("refusing rm -rf; the model will see this and retry safely");
}
return ToolCallDecision.allow();
})

On block, the runtime skips execution and returns TOOL_BLOCKED: {"reason": "..."} to the model as the tool result — the model sees the reason and adjusts, exactly like Claude Code's exit-2 deny.

Example: route a dangerous command to a sandbox

pi can "redirect to sandbox" but has no sandbox SPI — ai4j routes to Daytona/E2B. Configure a SandboxProvider, then route:

Agent agent = Agents.react()
.anthropicMessages(key, baseUrl).model("glm-5.1")
.toolExecutor(localExecutor)
.sandboxProvider(new E2BSandboxProvider()) // or DaytonaSandboxProvider
.toolInterceptor((call, ctx) -> {
String cmd = extractCommand(call);
if (cmd != null && isDangerous(cmd)) {
// run it isolated in a sandbox instead of on the host
return ToolCallDecision.routeTo(
SandboxSpec.builder().providerId("e2b").build(),
SandboxCommand.builder().command(cmd).build());
}
return ToolCallDecision.allow();
})
.build();

The runtime creates a sandbox session from the spec, runs the command, and feeds SANDBOX_RESULT: {"exitCode":0,"stdout":"..."} back to the model. The interceptor owns the tool→command mapping (it knows its tools); the runtime owns session creation/execution.

PostToolUse (afterToolCall)

ToolInterceptor has a second, default method that runs after a tool executes, with its output. Return block(reason) to replace the result fed back to the model — e.g. the output leaked a secret, or a post-edit lint failed. The default is allow() (no-op), so beforeToolCall lambdas still work.

.toolInterceptor(new ToolInterceptor() {
@Override
public ToolCallDecision beforeToolCall(AgentToolCall c, AgentContext ctx) {
return ToolCallDecision.allow();
}
@Override
public ToolCallDecision afterToolCall(AgentToolCall c, String output, AgentContext ctx) {
return containsSecret(output) ? ToolCallDecision.block("output redacted: secret detected")
: ToolCallDecision.allow();
}
})

The tool still ran (block is post-execution); the model receives TOOL_BLOCKED: <reason> instead of the raw output.

Prompt interception (UserPromptSubmit)

PromptInterceptor runs before the user's input is committed to the conversation — block a harmful prompt or rewrite it before the model sees it.

Agent agent = Agents.react()
.anthropicMessages(key, baseUrl).model("glm-5.1")
.promptInterceptor((input, ctx) -> {
if (looksLikeInjection(input)) {
return PromptDecision.block("possible prompt injection");
}
return PromptDecision.allow();
})
.build();

PromptDecision mirrors ToolCallDecision: allow() / block(reason) / modify(newInput). On block, the agent returns immediately with PROMPT_BLOCKED: <reason> and the model is never called.

Observe events (Stop / PreCompact / SessionStart / SessionEnd)

The remaining Claude-Code events are side-effects, not decisions (lint after edit, audit log on turn end, snapshot on compact). These ride on the existing observe-only AgentLifecycleHook, now directly registerable without the extension SPI:

Agent agent = Agents.react()
.modelClient(modelClient).model("m")
.lifecycleHook(new AgentLifecycleHook() {
@Override public String name() { return "audit"; }
@Override public void onEvent(AgentLifecycleEvent e) {
if (e.getType() == AgentLifecycleEventType.AFTER_TURN) {
metrics.turnEnded(e.getStep());
}
}
})
.build();

Event mapping: AFTER_TURN→Stop, ON_COMPACT→PreCompact, SESSION_START/SESSION_END→SessionStart/End.

Supported events

Claude Code eventTypeai4j mechanism
PreToolUseinterception (block/modify/routeTo)ToolInterceptor.beforeToolCall
PostToolUseinterception (block result)ToolInterceptor.afterToolCall
UserPromptSubmitinterception (block/modify prompt)PromptInterceptor.beforePrompt
StopobserveAgentLifecycleHookAFTER_TURN
PreCompactobserveAgentLifecycleHookON_COMPACT
SessionStart / SessionEndobserveAgentLifecycleHookSESSION_START/END

File-configured hooks (CLI)

End users configure all of the above via the workspace config JSON — no Java. The CLI bridges each event to external shell commands (any language): exit 2 blocks, {"decision":"modify",...} JSON rewrites, anything else continues.

"hooks": {
"preToolUse": [{ "command": "python guard.py", "match": "bash" }],
"postToolUse": [{ "command": "python scan.py", "match": "bash" }],
"userPromptSubmit":[{ "command": "python pii.py" }],
"stop": [{ "command": "python audit.py" }],
"preCompact": [{ "command": "python snapshot.py" }],
"sessionStart": [{ "command": "python on_start.py" }],
"sessionEnd": [{ "command": "python on_end.py" }]
}

Interceptor vs observe-only lifecycle hooks

AgentLifecycleHook (observe)ToolInterceptor (control)
ReturnvoidToolCallDecision
Can vetonoyes (block)
Can rewritenoyes (modify)
Can redirect to sandboxnoyes (routeTo)
Use fortelemetry, audit, tracepolicy, safety, prompt-shaping, sandbox routing

They coexist — register both; observe hooks see what happens, interceptors decide what happens.

Where this fits