Plugins
Add cross-cutting behavior to your agents without modifying agent code
A plugin is a reusable module that hooks into the agent lifecycle to add cross-cutting behavior — things like logging, error recovery, output filtering, or observability — without modifying your agent code.
How plugins work
A plugin extends BasePlugin and implements one or more callback hooks. These hooks fire at specific moments during execution:
User Message → beforeRun → beforeAgent → beforeModel → [LLM Call] → afterModel
→ beforeTool → [Tool Call] → afterTool → afterAgent → onEvent → afterRunAt each hook, a plugin can:
- Observe — return
undefinedto log or track without interfering - Intervene — return a value to short-circuit (e.g., return a cached response to skip the LLM call)
- Amend — modify the context objects before they're used
Quick start
import { AgentBuilder, ReflectAndRetryToolPlugin } from "@iqai/adk";
const { runner } = await AgentBuilder.withModel("gpt-4o")
.withFallbackModels("gpt-4o-mini", "gemini-2.0-flash")
.withPlugins(new ReflectAndRetryToolPlugin({ maxRetries: 3 }))
.build();This agent will automatically retry failed tools up to 3 times with reflection, and fall back to cheaper models if it gets rate limited — all without touching the agent's own logic.
Prebuilt plugins
ADK-TS ships four plugins you can use immediately:
Tool Output Filter
Uses LLM + JQ to shrink large tool outputs and save tokens
Reflect and Retry
When a tool fails, generates reflection guidance so the model can retry with corrected arguments
Model Fallback
Retries on rate limits (429) and falls back to alternative models
Langfuse
Sends traces, spans, and token usage to Langfuse for observability
Registering plugins
There are three ways to register plugins, each with different scope:
import { AgentBuilder, ReflectAndRetryToolPlugin } from "@iqai/adk";
const { runner } = await AgentBuilder.withModel("gemini-1.5-flash")
.withDescription("My assistant")
.withPlugins(new ReflectAndRetryToolPlugin({ maxRetries: 3 }))
.build();Plugins registered via withPlugins() are passed to the Runner that build() creates, so they apply globally.
import { InMemoryRunner, LlmAgent, ReflectAndRetryToolPlugin } from "@iqai/adk";
const agent = new LlmAgent({
name: "my_agent",
description: "My assistant",
model: "gemini-1.5-flash",
});
const runner = new InMemoryRunner(agent, {
appName: "my-app",
plugins: [new ReflectAndRetryToolPlugin({ maxRetries: 3 })],
pluginCloseTimeout: 5000, // optional: bound shutdown time
});Runner-level plugins apply to all agents, tools, and LLM calls managed by that runner.
import { LlmAgent, ReflectAndRetryToolPlugin } from "@iqai/adk";
const agent = new LlmAgent({
name: "my_agent",
description: "Agent with scoped plugins",
model: "gemini-1.5-flash",
plugins: [new ReflectAndRetryToolPlugin({ maxRetries: 3 })],
});Agent-level plugins are scoped to that specific agent rather than applying globally.
| Method | Scope | Use when |
|---|---|---|
AgentBuilder.withPlugins() | Global (via built Runner) | Most common — fluent API |
Runner / InMemoryRunner | Global (all agents, tools, LLMs) | Need direct Runner control |
LlmAgent({ plugins }) | Agent-scoped | Plugin should only apply to one agent |
Using multiple plugins
Plugins execute in registration order. Place them in the order that makes sense for your workflow:
import {
AgentBuilder,
LLMRegistry,
ToolOutputFilterPlugin,
ReflectAndRetryToolPlugin,
LangfusePlugin,
} from "@iqai/adk";
const { runner } = await AgentBuilder.withModel("gemini-1.5-pro")
.withDescription("Production assistant")
.withPlugins(
// 1. Monitor everything
new LangfusePlugin({
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
}),
// 2. Shrink large tool outputs
new ToolOutputFilterPlugin({
filterModel: LLMRegistry.newLLM("gemini-1.5-flash"),
}),
// 3. Retry failed tools
new ReflectAndRetryToolPlugin({ maxRetries: 3 }),
)
.build();The first plugin to return a non-undefined value from a callback short-circuits the rest — so order matters.
Plugins vs agent callbacks
Plugins and agent callbacks both use the same hook points, but serve different purposes:
| Plugins | Agent Callbacks | |
|---|---|---|
| Scope | Global — apply to all agents/tools/LLMs in the Runner | Local — apply only to the specific agent they're configured on |
| Use case | Cross-cutting concerns: logging, monitoring, caching, error recovery | Agent-specific logic: custom behavior for a single agent |
| Execution order | Run before agent callbacks | Run after plugin callbacks |
| Configuration | Once on Runner or AgentBuilder | Individually per agent |
If a plugin callback returns a value, the corresponding agent callback is skipped entirely.
Building a custom plugin
Extend BasePlugin and implement only the hooks you need:
import { BasePlugin, Agents, Models } from "@iqai/adk";
import type { Content } from "@google/genai";
export class SimpleLoggingPlugin extends BasePlugin {
constructor() {
super("simple_logging");
}
// Log every user message
async onUserMessageCallback(params: {
invocationContext: Agents.InvocationContext;
userMessage: Content;
}): Promise<Content | undefined> {
const text =
params.userMessage?.parts?.map(p => p.text || "").join("") || "";
console.log(`[user] ${text.slice(0, 200)}`);
return undefined; // observe only — don't modify
}
// Log token usage after each LLM call
async afterModelCallback(params: {
callbackContext: Agents.CallbackContext;
llmResponse: Models.LlmResponse;
}): Promise<Models.LlmResponse | undefined> {
const tokens = params.llmResponse.usageMetadata?.totalTokenCount;
console.log(`[model] ${tokens} tokens used`);
return undefined; // observe only
}
// Cleanup on shutdown
async close(): Promise<void> {
console.log("[plugin] closed");
}
}Then register it like any other plugin:
import { AgentBuilder } from "@iqai/adk";
import { SimpleLoggingPlugin } from "./simple-logging-plugin";
const { runner } = await AgentBuilder.withModel("gemini-1.5-flash")
.withDescription("My assistant")
.withPlugins(new SimpleLoggingPlugin())
.build();Callback hooks reference
Every hook is optional — implement only what you need. All hooks are async and receive a params object.
Lifecycle hooks
| Hook | Fires when | Return to intervene |
|---|---|---|
onUserMessageCallback | User sends a message (first hook to run) | Content to replace the message |
beforeRunCallback | Before any agent logic begins | Event to halt execution early |
afterRunCallback | After the Runner completes | Nothing (cleanup only) |
onEventCallback | Agent produces an event (text, tool result) | Event to replace the event |
Agent hooks
| Hook | Fires when | Return to intervene |
|---|---|---|
beforeAgentCallback | Before an agent starts executing | Content to skip the agent |
afterAgentCallback | After an agent completes | Content to replace the result |
Model hooks
| Hook | Fires when | Return to intervene |
|---|---|---|
beforeModelCallback | Before an LLM API call | LlmResponse to skip the call (e.g., cache hit) |
afterModelCallback | After a successful LLM response | LlmResponse to replace the response |
onModelErrorCallback | When an LLM call throws | LlmResponse to recover, or undefined to propagate |
Error type
The error parameter in error callbacks is typed as unknown in
BasePlugin. In practice, PluginManager always passes an Error instance,
but defensive type guards (e.g., params.error instanceof Error) are
recommended.
Tool hooks
| Hook | Fires when | Return to intervene |
|---|---|---|
beforeToolCallback | Before a tool executes | Record<string, any> to skip execution |
afterToolCallback | After a tool completes | Record<string, any> to replace the result |
onToolErrorCallback | When a tool throws | Record<string, any> to recover, or undefined to propagate |
Best practices
These apply to all plugins — prebuilt and custom. For plugin-specific guidance, see the individual plugin pages.
Design
- Single responsibility — each plugin should handle one concern (logging, caching, auth, etc.)
- Handle errors gracefully — plugin errors should not crash your application. Use try-catch.
- Document intervention — clearly note when your plugin returns values (short-circuits) vs observes
Configuration
- Start with defaults — all prebuilt plugins ship sensible defaults. Tune only after measuring.
- Test in development first — log callback frequency and latency before deploying to production
Multi-plugin ordering
- Registration order matters — plugins execute in the order you register them
- Filter before retry — place
ToolOutputFilterPluginbeforeReflectAndRetryToolPlugin - First intervention wins — the first plugin to return non-
undefinedshort-circuits the rest - Unique names required — each plugin must have a unique name or registration will throw
Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Plugin not executing | Not registered | Verify registration on Runner/AgentBuilder/LlmAgent |
| Callback not firing | Wrong method name | Check exact method names from BasePlugin (e.g., afterToolCallback not onAfterTool) |
| Plugin overriding another | Registration order | Reorder — first to return non-undefined wins |
| Shutdown hanging | close() never resolves | Set pluginCloseTimeout on Runner (default: 5000ms) |
| Duplicate name error | Two plugins share a name | Give each plugin a unique name in its constructor |
For plugin-specific issues, see the individual plugin pages. For general framework issues, see the troubleshooting guide.