Plugins in Google ADK: One Hook, Every Agent

cross-cutting concerns registered once on the Runner instead of copied onto every agent

In Module 09 you saw callbacks: six hooks around one agent’s lifecycle. They’re perfect for a guardrail on this agent. But logging, metrics, usage limits, and global policy aren’t about one agent — they’re about the whole app. Copying the same before_model_callback onto every LlmAgent you build is exactly the kind of duplication that rots. Google’s Agent Development Kit gives you a Plugin: the same lifecycle hooks, but registered once on the Runner, firing for every agent, model call, tool call, and event in the app.

Callback vs. plugin: same hooks, different scope

A plugin is not a new kind of hook. It reuses the callback set from Module 09 and lifts it from the agent to the Runner. The only thing that changes is where you register it and how widely it fires.

Callback vs. plugin: same hooks, different scope
Callback (Module 09) Plugin (this module)
Registered on one Agent the Runner (app-global)
Fires for that agent only every agent / model / tool in the app
Good for per-agent guardrails, tweaks logging, metrics, usage limits, global policy
Python LlmAgent(before_model_callback=…) Runner(…, plugins=[MyPlugin()])
Go llmagent.Config{BeforeModelCallbacks: …} runner.Config{PluginConfig: …}

That last row is the whole idea. Register once, observe everything — no agent needs to know the plugin exists.

The lifecycle hooks

A plugin overrides only the hooks it cares about; the rest are no-ops. The everyday set lines up almost one-to-one between the two SDKs:

The lifecycle hooks
Hook Fires… Python method Go field
before run once, as an invocation starts before_run_callback BeforeRunCallback
on user message when the user’s message arrives on_user_message_callback OnUserMessageCallback
on event for every event any agent yields on_event_callback OnEventCallback
before / after model around each model request before_model_callback / after_model_callback BeforeModelCallback / AfterModelCallback
before / after tool around each tool call before_tool_callback / after_tool_callback BeforeToolCallback / AfterToolCallback
after run once, as the invocation ends after_run_callback AfterRunCallback

And crucially, the same short-circuit contract applies. Returning None (Python) or nil (Go) means “observe only, change nothing.” Returning a value overrides: a before_model plugin that returns a response skips the model for the whole app; an on_event plugin that returns an event replaces it. It’s the Module 09 rule, applied globally.

How the Runner drives a plugin

The Runner owns a plugin manager. At each lifecycle point it walks every registered plugin’s callback in turn:

sequenceDiagram
    participant R as Runner
    participant P as Plugin (app-global)
    participant A as Agent
    R->>P: before_run
    loop each event the agent yields
        A-->>R: event
        R->>P: on_event
    end
    R->>P: after_run

One plugin can watch an entire run — and every agent inside it — without any agent knowing it’s there.

A counting plugin in Python

Python leans on inheritance: you subclass BasePlugin and override just the coroutines you need. Here’s a plugin that tallies how many runs start and how many events flow through the app.

from typing import Optional
from google.adk.events import Event
from google.adk.plugins.base_plugin import BasePlugin
from google.adk.agents.invocation_context import InvocationContext

class CountingPlugin(BasePlugin):
    def __init__(self, name: str = "counter") -> None:
        super().__init__(name=name)
        self.run_count = 0
        self.event_count = 0

    async def before_run_callback(self, *, invocation_context: InvocationContext) -> Optional[object]:
        self.run_count += 1
        return None  # observe only

    async def on_event_callback(self, *, invocation_context: InvocationContext, event: Event) -> Optional[Event]:
        self.event_count += 1
        return None

Register it on the Runner — not on the agent:

from google.adk.runners import InMemoryRunner

plugin = CountingPlugin()
runner = InMemoryRunner(agent=my_agent, app_name="demo", plugins=[plugin])
# ... after a run:  plugin.run_count == 1,  plugin.event_count == <events emitted>

That plugins=[…] kwarg still works, but google-adk 2.x nudges you toward an App object — App(name=…, root_agent=…, plugins=[p]) — which is where app-level config (context caching, event compaction) lives. Reach for it once you adopt those features.

The same plugin in Go

Go leans on composition: plugin.Config is a struct of callback fields, and you set only the ones you want. Left nil, the framework skips them.

func newCountingPlugin(c *counters) (*plugin.Plugin, error) {
    return plugin.New(plugin.Config{
        Name: "counter",
        BeforeRunCallback: func(agent.InvocationContext) (*genai.Content, error) {
            c.Runs++
            return nil, nil // observe only
        },
        OnEventCallback: func(_ agent.InvocationContext, _ *session.Event) (*session.Event, error) {
            c.Events++
            return nil, nil
        },
    })
}

Registration is likewise on the Runner, via PluginConfig — there is no App wrapper in Go, so this is the one place plugins live:

r, err := runner.New(runner.Config{
    AppName:        "demo",
    Agent:          myAgent,
    SessionService: session.InMemoryService(),
    PluginConfig:   runner.PluginConfig{Plugins: []*plugin.Plugin{p}},
})

Inheritance vs. a struct of function fields is the recurring Python/Go idiom split across ADK; the behavior is identical. Both put plugins on the Runner, and both fire for every agent under it.

Mental model: when a plugin beats a callback

Ask one question — does this concern belong to one agent, or to the app?

Both SDKs ship ready-made plugins you register exactly like the counter above — a LoggingPlugin that traces every step, and a reflect-and-retry plugin that feeds a failing tool’s error back to the model to self-heal. Notably, there’s no prebuilt PII redactor or content filter: for those, take the Module 09 guardrail pattern — a before_model hook that scans the request, or an on_event hook that redacts output — and lift it onto the Runner so it applies app-wide.

The demo behind this post is deliberately offline: a custom agent that deterministically yields three events, and the counting plugin asserting runs = 1, events = 3. Swap that emitter for a real LlmAgent and the same plugin observes real model and tool activity, unchanged. That’s the payoff — a plugin is decoupled from what the agents actually do.

Next in the series: Module 20 — Skills: packaging reusable agent capabilities.

Sources & References

Plugins vs callbacks in ADK
The Go SDK (google.golang.org/adk/v2)