Guides
Plugins
A plugin sees every tool call and every model request your agent makes. It is the place for redaction, metrics, a house style on prompts, or recording something your own systems need — anything that would otherwise be copied into every tool. It is an ordinary ADK plugin in your own process, not a platform extension point.
Declaring one
MANIFEST = AgentManifest(
...,
plugins=[".plugins.Redactor"],
)Declared, never discovered. Nothing scans for plugins, for the reason nothing scans for tools: a plugin nobody declared is one nobody reviewed, and this one sees every call the agent makes. Workflows declare them the same way.
The path names a BasePlugin subclass, which is constructed with no arguments, or an instance already built. It needs a module and an attribute — Redactor alone has nothing to import it from. A leading dot is relative to your target's own package, exactly as a Python import is, so .plugins.Redactor finds plugins.py beside your agent.py. Without one the path is absolute, which is how you name a plugin from a library.
Where a plugin may live
- Inside your target's own directory —
agents/invoice_triage/plugins.py. - In a package your
pyproject.tomlinstalls. The build installs it into the image.
Not in a directory of its own at the repo root. The image is built by copying agents/ and workflows/ and nothing else, so a top-level lib/ is on the path when you validate locally and absent from the image. The build reads your manifests inside that image, so the deploy is refused at BUILD with a message saying so — but only once you push, after elan validate has passed.
Writing one
# agents/invoice_triage/plugins.py
from google.adk.plugins.base_plugin import BasePlugin
class Redactor(BasePlugin):
def __init__(self, fields=("ssn",)):
super().__init__(name="redactor")
self.fields = tuple(fields)
async def before_tool_callback(self, *, tool, tool_args, tool_context):
for field in self.fields:
if field in tool_args:
tool_args[field] = "***"
return None # None means "carry on"
# An instance is how a plugin gets its settings: declare ".plugins.redactor".
redactor = Redactor(fields=["ssn", "dob"])super().__init__(name=...)is required.BasePlugintakes aname, so a subclass with no__init__of its own cannot be built — with an error about a parameter you never wrote. The loader catches that case and says what to do.- The name must be unique in the stack. The platform's own plugins use
ledger,ledger-tools,autonomy,mcp_apps,attachments,typingandobservability. - Returning a value short-circuits. ADK stops at the first plugin callback that returns something other than
Noneand uses it as the outcome. ReturnNoneunless you mean to replace it. - There is no config block in the manifest, on purpose: it is your code, configured in your code. Declare an instance.
Changing tool_args changes what the tool receives, and nothing earlier
The model's call — with the original value — is already in the conversation and on the event stream before any tool callback runs, and the policy gate has already judged the original arguments. What your change does reach is the tool itself and the tool's price, which is worked out after your plugin runs.
The callback names
ADK resolves callbacks by name, against a base class that implements every one as a no-op. A misspelling is therefore not an error: it is a method nobody calls, on a plugin that loads cleanly, in an agent that runs fine and does not do the thing. These are the fourteen ADK calls, every argument keyword-only:
| Callback | Fires | A non-None return |
|---|---|---|
| before_run_callback | Before the run starts. | Halts the run, ending it with what you returned. |
| on_user_message_callback | When the user's message arrives. | Replaces the message. |
| before_agent_callback | Before an agent's own logic. | Skips the agent; your content is its answer. |
| after_agent_callback | After it. | Replaces the agent's answer. |
| before_model_callback | Before each model request. | Skips the model call; your LlmResponse is used instead. |
| after_model_callback | After each model response. | Replaces the response. |
| before_tool_callback | Before each tool call. | Skips the tool; your dict is its result. |
| after_tool_callback | After each tool call. | Replaces the tool's result. |
| on_event_callback | For each event the run produces. | Replaces the event. |
| after_run_callback | After a run that did not raise — a pause included. | Nothing; it returns None. |
| on_model_error_callback | When a model call raises. | Your LlmResponse is used instead of the error. |
| on_tool_error_callback | When a tool call raises. | Your dict is used as the result instead of the error. |
| on_agent_error_callback | When an exception escapes an agent. | Notification only. |
| on_run_error_callback | When an exception escapes the runner itself. | Notification only. |
The platform checks for near misses twice — in the build, so it is in your build log before the deploy finishes, and again at pod start:
warning: invoice-triage: plugin .plugins.Redactor defines before_tool_calback(), which ADK will never call. Did you mean before_tool_callback()?
It flags near misses, not prefixes: before_tool_calback and on_tool_eror_callback are caught; on_start and an ordinary helper are not. Read the warning — the agent otherwise runs perfectly and skips your plugin's whole purpose.
A plugin that keeps state per run
after_run_callbackruns only for a run that did not raise. An exception from the runner itself reacheson_run_error_callbackand nothing else, so a plugin that opens something per run needs all three error hooks — run, model and tool — to close it.- It does run when a run pauses, because a pause is the run stopping cleanly. The resume arrives later as its own run, with the same invocation id.
on_event_callbackdoes not fire for a run halted inbefore_run_callback— an insufficient-funds refusal, for one. Do not rely on it to see every run.
Where yours runs
ADK runs plugins in registration order, and the first callback to return a value stops the rest. So the platform's gates go first: a plugin placed ahead of them would not subvert the ledger or the policy gate, it would stop them running at all. Yours are appended after them, so a refusal never reaches your code.
- LedgerPlugingate
Opens a hold before the run and settles what it used after. Refuses a run, or its next model call, that the payer cannot cover. Absent for a target with
unbilled=True. - AutonomyPlugingate
Resolves each declared tool call against policy — runs it, asks, or refuses it. Asks on behalf of an MCP tool, which has no body here to ask from.
- McpAppsPlugin
Turns an MCP tool's declared view into a surface the console renders.
- AttachmentsPlugin
Puts the real bytes of a file the user's message names into the model request.
- TypingPlugin
The “thinking” and “running a tool” status a chat shows.
- ObservabilityPlugin
Logs a summary of each run.
- Your pluginsyours
In the order you declared them.
- The tool metercounts
Records each priced tool call that the whole stack let through. It sits after yours, so a call your plugin answered from a cache or refused is not charged to your user.
That is an ordering guarantee, not a security boundary. Enforcement that matters — the network policy, resource quotas, admission — is outside your process, because inside it a tool could always have patched the same objects. The order exists so the platform's gates are not broken by accident.
- A nested run does not see your plugin. A workflow called as a tool runs with no plugin callbacks at all, and a sub-agent should be wrapped with
include_plugins=False— the platform's plugins are not re-entrant. See another agent or workflow. - Do not reimplement the ledger or the policy gate. To make a tool wait for a person, declare it in actions; that one statement files the approval, tells
t0what to refuse and caps how far a tenant may raise it. - You do not need a
before_model_callbackto attach an upload. The attachments plugin already does it, and it runs before yours, so the file is already inllm_request.contentswhen you see it. Append parts if you must; never mutate an existing part in place — its payload is shared with the stored conversation. What it attaches is in building an agent.
Failure modes
Your plugins are imported during the build, inside the image that will run them, because a dotted path is only a string until something tries it. A bad declaration therefore refuses the deploy rather than producing a pod that crash-loops on an image that already passed every other check. elan validate runs the same import on your laptop.
| What you wrote | What you get |
|---|---|
A path without a module and an attribute — Redactor on its own | Refused by the manifest: it shows the two shapes that work. |
| A module that cannot be imported | Refused at BUILD, naming the path and where a plugin may live. |
| A module without that attribute | Refused, naming the module and the attribute it lacks. |
| A class that cannot be built with no arguments | Refused, saying to add an __init__ or to declare an instance instead. |
Something that is not a BasePlugin | Refused, naming what it actually is. |
| A plugin at the repo root | Imports when you validate on your laptop, where the repo is on the path; refused at BUILD, where the image holds only agents/ and workflows/. |
| A near miss on a callback name | A warning, not a refusal — in the build log and again at pod start. |
A name another plugin already uses | Not caught at build. The pod fails at start: ADK refuses two plugins with one name. |
Read a build's warnings with elan logs <deployment-id>, and what a running pod says with elan logs --workload <name> --project <project>. A near miss is only a warning because a method that merely resembles a callback may be a perfectly ordinary helper, and refusing a deploy over that would cost more than it saved.