Google ADK + AxonFlow Integration
Prerequisites: Python 3.10+, AxonFlow running (Getting Started), pip install google-adk>=2.0 axonflow>=8.0
What This Integration Gives You
Google's Agent Development Kit (ADK) ships a first-class plugin model: BasePlugin exposes six hooks that fire around every model call and every tool call across every agent on a Runner. AxonFlow's plugin (axonflow_adk.AxonFlowPlugin) implements all six hooks and routes them through the existing axonflow Python SDK.
The practical result: register the plugin once on your Runner, and AxonFlow governs every interaction — without touching individual agents, tool definitions, or chain code. PII detection, deny short-circuits, HITL approval gates, audit logging, and tool-output redaction all light up automatically.
| Capability | How it surfaces |
|---|---|
| Pre-LLM policy enforcement | before_model_callback calls pre_check. Denials become an LlmResponse short-circuit the agent sees as the model output. |
| HITL approval gate | When policy returns require_approval, the plugin polls get_hitl_request until approved/rejected/expired. |
| Tool-input policy | before_tool_callback calls check_tool_input. Denials return {"error": "[AxonFlow] <reason>"}. |
| Tool-output redaction | after_tool_callback calls check_tool_output. PII-redacted output replaces the original. |
| Audit trail | after_model_callback + on_tool_error_callback route to audit_llm_call / audit_tool_call. |
| MCP toolset | axonflow_mcp_toolset() returns an McpToolset pointed at AxonFlow's MCP endpoint over Streamable HTTP. |
The plugin reuses the published axonflow Python SDK (v8.0+) and inherits its auth, retry, and observability. There is no second HTTP client.
Quickstart
Five lines of plugin registration is the entire integration:
from google.adk.runners import InMemoryRunner
from google.adk.agents import LlmAgent
from axonflow_adk import AxonFlowPlugin
agent = LlmAgent(model="gemini-2.0-flash", name="loan_desk", instruction="...")
runner = InMemoryRunner(
agent=agent,
app_name="loan_desk",
plugins=[AxonFlowPlugin(
endpoint="http://localhost:8080",
client_id="loan-desk",
client_secret="secret-from-axonflow",
)],
)
That is it. Every call the runner makes through agent (and any sub-agents whose tools fire on the same Runner) goes through AxonFlow.
Reuse an existing AxonFlow client when you already have one in scope:
from axonflow import AxonFlow
from axonflow_adk import AxonFlowPlugin
axon = AxonFlow(endpoint="...", client_id="...", client_secret="...")
runner = InMemoryRunner(
agent=agent,
app_name="loan_desk",
plugins=[AxonFlowPlugin.from_client(axon)],
)
Hook-to-Endpoint Reference
The plugin implements all six BasePlugin hooks from google/adk-python. Signatures match exactly (keyword-only args, async).
| ADK hook | AxonFlow call | Returns on deny | Fails open? |
|---|---|---|---|
before_model_callback | pre_check | LlmResponse with policy-denial text | Yes (except require_approval — fail closed) |
after_model_callback | audit_llm_call | never blocks (audit only) | Yes |
before_tool_callback | check_tool_input | {"error": "[AxonFlow] <reason>"} | Yes (except require_approval — fail closed) |
after_tool_callback | check_tool_output | redacted dict OR {"error": ...} on hard deny | Yes |
on_tool_error_callback | audit_tool_call | never blocks (audit only) | Yes |
on_user_message_callback | no-op (v1) | n/a | n/a |
The on_user_message_callback hook is intentionally a no-op in v1. Returning non-None Content there would silently replace the user's message — the wrong tool for governance. Audit of the user prompt happens at before_model_callback time via pre_check.
MCP Toolset Helper
If you want AxonFlow's governed MCP connectors (PostgreSQL, Snowflake, GCS, …) as ADK tools, axonflow_mcp_toolset() returns a ready-to-use McpToolset configured against the AxonFlow agent's MCP endpoint over Streamable HTTP. The helper emits a canonical Authorization: Basic <base64(client_id:client_secret)> header (or Bearer <token> when bearer_token= is passed) — these are the only auth shapes AxonFlow's MCP server recognizes.
from google.adk.agents import LlmAgent
from axonflow_adk import AxonFlowPlugin, axonflow_mcp_toolset
agent = LlmAgent(
model="gemini-2.0-flash",
name="postgres_governed",
instruction="Answer questions about the production DB.",
tools=[axonflow_mcp_toolset(
endpoint="http://localhost:8080",
client_id="my-app",
client_secret="secret",
)],
)
MCP tools surfaced this way are governed twice: once by AxonFlow's MCP connector layer (e.g. SQL injection detection, row-cap enforcement) and again by the plugin's before_tool_callback / after_tool_callback. Two independent gates, by design — the connector layer enforces connector-specific policy, the plugin layer enforces ADK-tool-shaped policy.
Reference: Streamable HTTP transport on the ADK side.
HITL Approval Flow — 4-step
When AxonFlow policy evaluates to require_approval, the plugin runs the full 4-step HITL flow by default (enable_hitl_polling=True):
before_model_callback / before_tool_callback
│
├─ STEP 1 — gate (pre_check / check_tool_input)
│ returns blocked, BlockReason == "require_approval"
│
├─ STEP 2 — create_hitl_request POST /api/v1/hitl/queue
│ returns approval_id (uuid)
│ (the gate-minted context_id / decision_id is for audit
│ linkage only — the canonical handle is what create returns)
│
├─ STEP 3 — poll get_hitl_request(approval_id)
│ every approval_poll_interval_seconds (default 2s)
│ uses a LOCAL counter; polling errors do NOT trip the
│ shared circuit breaker
│
└─ STEP 4 — resume or deny:
├─ status="approved" → return None (continue)
├─ status="rejected" | "expired" → return deny short-circuit
├─ N consecutive poll failures → return deny short-circuit
└─ time > approval_max_wait_seconds → return deny short-circuit
Defaults: 2-second poll interval, 300-second total ceiling. Both tunable via AxonFlowPluginConfig.
The platform's pre_check and check_tool_input gates set BlockReason="require_approval" (matched exactly against the wire-stable sentinel string — substring matching previously false-positived on reasons containing the word "approval") and mint a correlation context_id / decision_id. They do not create the HITL queue row at those sites — that is the plugin's responsibility via step 2. This is intentional platform architecture: the decision lives in pre_check, the row creation is an explicit POST so callers can carry application-specific context (triggered_policy_id, severity, compliance_framework).
The polling path (step 3) is the only fail-closed path in the plugin. Approvals are safety-critical; defaulting to "allow" on an AxonFlow outage during an approval gate would defeat the gate.
Approving / rejecting out-of-band
On entry to step 3 the plugin emits a single INFO log:
axonflow hitl AWAITING APPROVAL: request_id=<uuid>; approve via
POST /api/v1/hitl/queue/<uuid>/{approve|reject} (poll_interval=2.0s, max_wait=300s)
Default-configured loggers will surface it (INFO, not DEBUG). The request_id is the canonical handle for both approve and reject — pipe it into your operator UI / Slack bot / pager.
The reviewer (UI, Slack bot, internal portal) then posts the decision via:
curl -X POST $AXONFLOW_ENDPOINT/api/v1/hitl/queue/<approval_id>/approve \
-H 'Content-Type: application/json' \
-d '{"reviewer_id":"compliance","reviewer_email":"[email protected]"}'
(or …/<approval_id>/reject for the reject path.) The plugin's polling loop sees the status change on its next iteration and resumes the agent's call (or short-circuits with a deny).
Opting out — deny-fast mode
Set enable_hitl_polling=False on the config to short-circuit require_approval immediately without enqueuing a row. The host app then drives its own approval workflow (e.g. ticket system, Slack, internal portal). Use this when AxonFlow's HITL queue is not your system of record for approvals.
For a working example see examples/loan_disbursement_agent.py.
Failure Semantics
A buggy or unreachable AxonFlow must not break the agent. The plugin defends with:
- Per-hook timeout — default 5 s, configurable via
call_timeout_seconds. AxonFlow calls that exceed this are abandoned. - Half-open circuit breaker — default opens after 5 consecutive failures, recovers after 30 s. While open, every hook skips the AxonFlow call entirely (no timeout overhead, no log spam).
- Fail open by default — every hook except
_await_hitl_decisionreturnsNoneon error/timeout/open-circuit, letting the model or tool call proceed unguarded.
If your compliance posture requires fail-closed governance, override the defaults: set a very long breaker_recovery_seconds and add a watchdog that alerts on breaker-open transitions. The plugin logs every guarded failure at WARNING level so the runtime can be wired into your alerting.
Tuning
All knobs live on AxonFlowPluginConfig:
from axonflow_adk import AxonFlowPlugin, AxonFlowPluginConfig
plugin = AxonFlowPlugin(
endpoint="http://localhost:8080",
client_id="loan-desk",
client_secret="...",
config=AxonFlowPluginConfig(
call_timeout_seconds=5.0, # per-hook deadline
approval_poll_interval_seconds=2.0, # HITL polling cadence
approval_max_wait_seconds=300.0, # HITL timeout ceiling
breaker_failure_threshold=5, # consecutive failures before opening
breaker_recovery_seconds=30.0, # how long the breaker stays open
request_type="loan-desk-chat", # audit label for pre_check
tool_connector_type="loan-desk-tool", # audit label for tool I/O
tenant_id="bank-1", # multi-tenant scoping
extra_context={"product": "loan_desk"}, # static fields on every audit
),
)
user_token is resolved per call in this order:
callback_context.state["axonflow_user_token"]config.default_user_token(default"anonymous")
The plugin does not fall back to ADK's callback_context.user_id. In enterprise mode the platform expects a JWT signed with the tenant key — silently passing a raw identifier would 401 every call, and the plugin's fail-open default would then silently disable governance.
For enterprise mode, set state["axonflow_user_token"] to a JWT BEFORE each runner.run_async(...) call:
session = runner.session_service.create_session(
app_name="loan_desk", user_id="cust-001", session_id="sess-A",
)
session.state["axonflow_user_token"] = generate_axonflow_jwt(user_id="cust-001")
For community mode, leave the state key unset — default_user_token="anonymous" is what the platform expects.
Audit endpoints are enterprise-only
audit_llm_call (called by after_model_callback) and audit_tool_call (called by on_tool_error_callback) are enterprise-tier features. On a community-mode platform they return 401 and the plugin fails open — the audit hooks effectively become silent no-ops. This matches the rest of the AxonFlow SDK surface.
Known ADK Gotchas
The plugin documents — and where applicable, regression-tests — three upstream ADK behaviors that affect governance:
| Issue | Behavior | Plugin stance |
|---|---|---|
google/adk-python#2809 AgentTool plugin isolation | AgentTool constructs an isolated inner Runner that does not inherit the parent Runner's plugins. Sub-agent tool calls are not governed by the parent plugin. | Documented. Test test_agent_tool_plugin_isolation_gotcha_is_documented pins the behavior so future ADK fixes (or regressions) surface in CI. |
google/adk-python#4464 InMemoryRunner plugin lifecycle | Plugin lifecycle is bound to the Runner, not the session. Re-creating the Runner re-creates the AxonFlow client. | Documented. For long-running services keep one Runner per app; do not re-create per request. |
google/adk-python#4509 before_model_callback corner | A non-None LlmResponse return can corner-case downstream tool dispatch in older ADK builds. | Documented. Mitigation: pin google-adk>=2.0 (or newer). |
If you need governance on sub-agents wrapped by AgentTool, register AxonFlowPlugin on the inner Runner as well, or use RemoteA2aAgent for cross-process delegation.
What's NOT in v1
For transparency, the v1 scope deliberately excludes:
- User-message rewriting.
on_user_message_callbackreturnsNone. ReturningContentwould silently replace the user's message; we treat that as a deliberate ADR-pinned future decision. - Output policy on model responses.
after_model_callbackaudits but does not block or rewrite. Output policy on model output is owned by the platform layer (see the Media Governance and Policies overview docs). - Zero-dependency rewrite. The plugin calls the existing
axonflowPython SDK rather than re-implementing the REST surface. Auth, retry, and observability are inherited. - Cross-language parity. ADK Java/Go/TypeScript/Kotlin plugins are a separate workstream tracked on the same epic.
Source + Issues
- Plugin source (public mirror):
axonflow/examples/integrations/google-adk-plugin/ - Issues + feedback:
getaxonflow/axonflowissues - ADK plugin docs: adk.dev/plugins/
- ADK BasePlugin source: google/adk-python — base_plugin.py
- MCP toolset on the ADK side: adk.dev/tools-custom/mcp-tools/
