Interceptor Extensions
INTERCEPTOR is the seventh ExtensionCapability member. Unlike LIFECYCLE (observe-only event
notification) and GUARDRAIL (allow/deny only), interceptors return control-flow decisions
the runtime honors: rewriting tool arguments, routing dangerous calls into a sandbox, intercepting
user input, and adjusting model parameters per request.
Typical scenarios:
- Compliance/safety plugins: rewrite tool arguments (e.g. force a working directory), route
bash-style tools to a Daytona/E2B sandbox - Injection defense / PII redaction: rewrite or block the prompt before it reaches memory or the model
- Multi-tenant policy: rewrite
systemPromptper tenant, captemperatureormaxOutputTokens - Audit hardening:
afterToolCallinspects tool output for leaked secrets and can replace what is fed back to the model
InterceptorRegistry and the three interceptor interfaces are currently annotated
@Experimental(since = "2.5.1") — signatures may evolve across minor versions; pin an exact
version when upgrading. See Extension layering — SPI stability matrix.
1. Declare the capability and register interceptors
The manifest must declare INTERCEPTOR; otherwise context.interceptors().register*(...) inside
apply(...) throws ExtensionException("did not declare capability: interceptor").
public ExtensionManifest manifest() {
return ExtensionManifest.builder()
.id("safety-pack")
.name("Safety Pack")
.capability(ExtensionCapability.INTERCEPTOR)
.build();
}
public void apply(ExtensionContext context) {
context.interceptors().registerToolCall(new SandboxBashInterceptor());
context.interceptors().registerPrompt(new PiiRedactor());
context.interceptors().registerModelRequest(new TenantScope());
}
Three interceptor interfaces, each mapped to one native interception surface of the agent runtime:
| Extension interface | Native counterpart | Decision types |
|---|---|---|
ExtensionToolCallInterceptor | ToolInterceptor (PreToolUse / PostToolUse) | allow / block / modify / routeTo |
ExtensionPromptInterceptor | PromptInterceptor (UserPromptSubmit) | allow / block / modify |
ExtensionModelRequestInterceptor | ModelRequestHook | allow / modify (no block — vetoing a model call is a guardrail's job) |
2. Tool-call interceptor
beforeToolCall runs before the tool executes; afterToolCall re-evaluates with the tool output
(default: allow). The request carrier is ExtensionToolCallRequest with four portable fields
(name / arguments / callId / type).
request.getName() is the host-facing tool id: plugin-contributed tools appear here in
namespaced form plugin__<extensionId>__<tool> (e.g. plugin__ask-user__ask_user), while
built-in tools keep their original names (bash, read_file, ...). Match plugin tools by
the full namespaced id or by the __<tool> suffix.
public class SandboxBashInterceptor implements ExtensionToolCallInterceptor {
public String name() { return "sandbox-bash"; }
public ExtensionToolCallDecision beforeToolCall(ExtensionToolCallRequest request) {
if (!"bash".equals(request.getName())) {
return ExtensionToolCallDecision.allow();
}
// Route shell-style tools into a sandbox instead of local execution
return ExtensionToolCallDecision.routeTo("daytona", "default", extractCommand(request));
}
public ExtensionToolCallDecision afterToolCall(ExtensionToolCallRequest request, String output) {
if (output != null && output.contains("AKIA")) {
return ExtensionToolCallDecision.block("tool output contained a credential");
}
return ExtensionToolCallDecision.allow();
}
}
The four ExtensionToolCallDecision verdicts:
allow(): proceed unchangedblock(reason): veto the call;reasonis fed back to the model as the tool result so it can adjustmodify(newName, newArguments): rewrite then execute — pass nullnewNameto keep the tool name,newArgumentsreplaces the raw JSON arguments (callId/type/metadata preserved)routeTo(providerId, profile, command): redirect execution to a sandbox provider (Daytona/E2B/…); the extension owns the tool→command mapping, the runtime owns session creation and execution
3. Prompt interceptor
ExtensionPromptInterceptor.intercept(ExtensionPromptRequest) runs before user input enters
memory / the model:
allow(): pass throughblock(reason): the agent does not run this turn;reasonbecomes the run output (PROMPT_BLOCKED: ...)modify(modifiedInput): the rewritten input proceeds
context.interceptors().registerPrompt(new ExtensionPromptInterceptor() {
public String name() { return "pii-redactor"; }
public ExtensionPromptDecision intercept(ExtensionPromptRequest request) {
String cleaned = Redactor.maskPhones(request.getInput());
return cleaned.equals(request.getInput())
? ExtensionPromptDecision.allow()
: ExtensionPromptDecision.modify(cleaned);
}
});
4. Model-request interceptor
ExtensionModelRequestInterceptor.intercept(ExtensionModelRequest) runs before each model
request. The request view exposes six read-only scalar fields (model / systemPrompt /
instructions / temperature / topP / maxOutputTokens) — rewriting conversation history is a
host-side concern and is not open to extensions.
Decisions are expressed via ExtensionModelRequestDecision with* factories; null fields keep
the original value, and merge stacks overrides:
context.interceptors().registerModelRequest(new ExtensionModelRequestInterceptor() {
public String name() { return "tenant-scope"; }
public ExtensionModelRequestDecision intercept(ExtensionModelRequest request) {
return ExtensionModelRequestDecision.withSystemPrompt("You are tenant acme's assistant.")
.merge(ExtensionModelRequestDecision.withMaxOutputTokens(2048));
}
});
5. Composition order and activation semantics
- Activation: interceptors follow lifecycle-hook semantics — they take effect as soon as the
extension is
enable(...)d; noexposeTool(...)/allow*(...)per-resource authorization. - Host first: interceptors registered on
AgentBuilder(toolInterceptor/promptInterceptor/modelRequestHook) evaluate before extension interceptors, which run in registration order. - Chained modification: every interceptor sees the current effective value — a
MODIFYfeeds downstream evaluators;BLOCK/ROUTE_TOshort-circuit. - Model request: the host hook's output is the first extension interceptor's input, and so on.
- Name uniqueness: interceptors are unique by
name()across extensions; duplicates fail fast atsnapshot()(duplicate ... interceptor id).
6. Enable and assemble
ExtensionRegistry registry = ExtensionRegistry.of(new SafetyPackExtension())
.enable("safety-pack");
Agent agent = Agents.react()
.modelClient(modelClient)
.model("doubao-seed-1-8-251228")
.toolRegistry(tools)
.sandboxProvider(daytonaProvider) // routeTo decisions need a configured sandbox provider
.extensions(registry)
.build();
AgentBuilder.extensions(registry) composes extension interceptors with host interceptors into a
single chain for the runtime; the runtime still only sees the native ToolInterceptor /
PromptInterceptor / ModelRequestHook interfaces — the extension-side contract and runtime-side
adaptation are bridged by ExtensionInterceptors, so ai4j-extension-api does not depend on
ai4j-agent.
- Guardrail: allow/deny only, simplest semantics, per-resource authorization
(
allowGuardrail). - Interceptor: can rewrite, route, and intercept input — a decision-making hook, active on enable.
- Lifecycle: read-only notification; it must never alter control flow.