Google ADK’s plugin SPI is the part of the runtime that makes a compliance plane like Regulus possible. Before ADK, building runtime controls for an agent meant Spring AOP, bytecode weaving, or wrapping the runtime entirely. ADK ships a documented extension contractBasePlugin with named callback hooks — and that’s what compliance work attaches to.

This article walks through the callbacks in order of when they fire, what context each receives, and what the right plugin to attach is.

The agent’s lifecycle in callbacks #

When you call runner.run(invocation), ADK walks through this sequence:

  1. BeforeAgentCallback. Once per agent invocation. The agent has been resolved, the Principal is available, the session is loaded.
  2. (Repeated loop:) Model invocation.
    • BeforeModelCallback. About to call the model.
    • Model produces a response.
    • AfterModelCallback. Model returned.
  3. (Repeated loop:) Tool dispatch (if the model picked a tool).
    • BeforeToolCallback. About to dispatch the tool.
    • Tool executes.
    • AfterToolCallback. Tool returned.
  4. AfterAgentCallback. Once per agent invocation. Final response is going back to the caller.

The loop in step 2/3 can run multiple times — the model can request tools, get responses, request more tools, until it produces a final response.

Where to attach what #

The trick is putting each compliance concern at the seam where it has the right context.

BeforeAgentCallback — agent-level gating #

public class RegulusKillSwitchPlugin extends BasePlugin {
  @Override
  public Optional<PluginContext> beforeAgentCallback(
      AgentCallbackContext ctx) {
    if (killSwitch.isEngaged(ctx.getAgentName())) {
      audit.emit(killSwitchEngagedEvent(ctx));
      return Optional.of(ctx.cancel("KILL_SWITCH_ENGAGED"));
    }
    return Optional.empty();
  }
}

At this point you have the agent name, the Principal, and the session. You don’t yet have model invocations or tool dispatches. Right for:

  • Kill-switch check (this agent is collapsed)
  • Identity expiry check (Principal credentials still valid)
  • Profile resolution (active regulation profiles for this Principal’s jurisdiction, cached for the rest of the invocation)
  • Coarse-grained policy (this Principal isn’t authorised to invoke this agent at all)

BeforeModelCallback — pre-model gating #

The model’s about to be called. You see the prompt the model will receive. Right for:

  • PII redaction on the outbound prompt (privacy plugin)
  • Model-risk tier gating (this tier-3 model needs HITL first)
  • Prompt-level safety checks (jailbreak detection)
  • Model selection enforcement (this Principal can only use registered Tier-1 models)
public class RegulusPrivacyPlugin extends BasePlugin {
  @Override
  public Optional<ModelInvocation> beforeModelCallback(
      ModelCallbackContext ctx) {
    String redactedPrompt = piiRedactor.redact(ctx.getPrompt());
    if (!redactedPrompt.equals(ctx.getPrompt())) {
      audit.emit(piiRedactionEvent(ctx, redactedPrompt));
    }
    return Optional.of(ctx.withPrompt(redactedPrompt));
  }
}

AfterModelCallback — post-model gating #

The model returned. You see the response. Right for:

  • Output-side PII re-redaction (defends against training-set memorisation leaks)
  • Output safety (toxicity, jailbreak detection on responses)
  • Outcome capture (model invocation outcome lands in the audit chain)
  • Refusal pattern enforcement (the model said it couldn’t do something; capture that as a refusal event)

BeforeToolCallback — the chokepoint #

This is where the policy plugin lives. The model has picked a tool. You see the tool name, arguments, and the calling Principal. Decisions made here are runtime-binding — if you deny here, the tool doesn’t execute. This is where:

  • Purpose-limitation check (Principal’s purpose matches tool’s authorised purposes)
  • Authorisation check (Principal has permission for this tool’s required scopes)
  • Argument validation (no PII in tool arguments unless authorised)
  • Residency check (tool’s region matches Principal’s allowed regions)
  • Rate limiting / quota
  • Cost gating (expensive tools require dual-control)
public class RegulusPolicyPlugin extends BasePlugin {
  @Override
  public Optional<PluginContext> beforeToolCallback(
      ToolCallbackContext ctx) {
    PolicyDecision decision = policy.evaluate(ctx);
    audit.emit(toolCallEvent(ctx, decision));
    if (decision.isDeny()) {
      return Optional.of(ctx.cancel(decision.getClause()));
    }
    return Optional.empty();
  }
}

AfterToolCallback — outcome capture #

The tool executed (or didn’t). The result is back. Right for:

  • Outcome recording (the tool succeeded, with these side effects)
  • Error capture (the tool failed; what was the cause)
  • Hash chain append (the audit event lands here in the chained order)

AfterAgentCallback — final wrap-up #

The agent invocation is wrapping up. The final response is going back to the caller. Right for:

  • GRC adapter dispatch (route the session’s events to your IRM tool)
  • Compaction trigger (this session’s events older than the retention window get compacted)
  • Cleanup (cached profile, transient state)

Composing multiple plugins #

When you register multiple plugins, ADK runs them in priority order. Regulus pins the priority order so:

  1. Kill-switch plugin runs first (highest priority on BeforeAgent).
  2. Identity expiry guard next.
  3. Policy plugin in the middle.
  4. Privacy plugin near the bottom (after policy has decided the tool is allowed at all).
  5. Audit plugin runs last on the After* side (after every other plugin has had its say).

This ordering matters. If you put audit first on AfterTool, you log before the framework citations are attached by upstream plugins. If you put privacy before policy on BeforeTool, you redact PII from arguments that a policy decision might have wanted to inspect.

The service-extension seams #

Beyond the callback SPI, ADK also exposes service extensions — the SessionService, MemoryService, ArtifactService interfaces. Regulus extends these in addition to the plugins:

  • RegulusVertexAiSessionService extends VertexAiSessionService — adds residency on session reads/writes.
  • RegulusFirestoreMemoryService extends FirestoreMemoryService — adds re-redaction on memory writes, residency on cross-region copies.
  • RegulusGcsArtifactService extends GcsArtifactService — adds region pinning on artifact creation.

These aren’t callbacks but inheritance points. The base class does its work; the Regulus subclass adds the gating at the read/write seams. ADK’s choice to expose these as interfaces (rather than internal classes) is what makes the extension model work.

What ADK doesn’t expose (yet) #

A few seams that would be useful but aren’t there:

  • A stable extension point on SessionService create that gives plugins a chance to mutate the new session before it’s persisted.
  • Typed metadata on ToolConfirmation — currently the human-review justification is free-form text; structured fields would let audit envelopes carry it cleanly.
  • An ordering primitive within a single callback type (currently it’s priority-driven, which is fine but not as expressive as pipeline-style ordering).

Both are noted in the ADK issue tracker; Regulus works around them in v0.2.1 and tracks the upstream fixes.

What this looks like end-to-end #

A full compliance-plane wiring on top of ADK:

App.builder()
  .name("credit-decision")
  .agent(myAgent)
  .runner(new Runner())
  .plugins(RegulusPlugins.builder()
    .profile("eu-ai-act")
    .profile("uk-gdpr")
    .profile("fca-sysc")
    .framework("nist-ai-rmf")
    .framework("iso-42001")
    .grcAdapter(ServiceNowIrm.fromYaml())
    .build())
  .sessionService(new RegulusVertexAiSessionService())
  .memoryService(new RegulusFirestoreMemoryService())
  .artifactService(new RegulusGcsArtifactService())
  .build()
  .run(invocation);

That’s the contract. Two integrations and one builder. The plugin SPI is the reason any of this is possible without forking the runtime.

For more, see how Regulus extends ADK and the plugin index.