Agent Governance
Google ADK Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Google's Agent Development Kit (ADK) makes wiring Stripe into an agent trivial: register a Python function as a tool, hand it to an LlmAgent, and let the model decide when to charge. What ADK doesn't handle automatically is what happens when that tool runs inside a ParallelAgent — because both branches execute simultaneously, and two branches that each call Stripe can each fire a charge for the same operation.
This post covers three failure modes specific to ADK's multi-agent architecture — ParallelAgent concurrent billing, LoopAgent retry without idempotency keys, and session state replay — and shows the two-layer governance pattern that closes all three: a restricted Stripe API key as a first layer, and per-run vault keys via a spend-cap proxy as a second.
The standard Google ADK Stripe pattern
ADK's tool system is built around Python functions registered with @agent.tool or passed as callables to an LlmAgent. A single-agent billing workflow looks like this:
import os
import stripe
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # sk_live_...
def charge_stripe(customer_id: str, amount_cents: int, description: str) -> dict:
"""Charge a Stripe customer and return the charge details."""
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=description,
)
return {"charge_id": charge.id, "status": charge.status}
billing_agent = LlmAgent(
model="gemini-2.0-flash",
name="billing_agent",
instruction="You handle Stripe billing for agent workflows.",
tools=[FunctionTool(charge_stripe)],
)
session_service = InMemorySessionService()
runner = Runner(agent=billing_agent, app_name="billing", session_service=session_service)
session = session_service.create_session(app_name="billing", user_id="u1", session_id="s1")
result = runner.run(
user_id="u1",
session_id="s1",
new_message=types.Content(
role="user",
parts=[types.Part(text="Charge customer cus_Abc123 $29 for the Hobby plan upgrade")]
),
)
for event in result:
if event.is_final_response():
print(event.content.parts[0].text)
This works correctly under normal conditions. The risk emerges when you compose this agent into a larger ADK workflow — specifically when you use ParallelAgent or LoopAgent, both of which ADK provides as first-class orchestration primitives.
Failure mode 1: ParallelAgent fires Stripe tools concurrently
ADK's ParallelAgent runs a list of sub-agents simultaneously, with each branch executing in its own invocation context. This is powerful for independent tasks — but "independent" is hard to guarantee when multiple branches share access to the same Stripe key and both have billing tools.
from google.adk.agents import ParallelAgent, LlmAgent
from google.adk.tools import FunctionTool
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def charge_stripe(customer_id: str, amount_cents: int, description: str) -> dict:
charge = stripe.Charge.create(
amount=amount_cents, currency="usd",
customer=customer_id, description=description,
)
return {"charge_id": charge.id}
# Both agents share the same bare Stripe key
billing_agent = LlmAgent(
model="gemini-2.0-flash",
name="biller",
instruction="Charge the customer for their subscription.",
tools=[FunctionTool(charge_stripe)],
)
audit_agent = LlmAgent(
model="gemini-2.0-flash",
name="auditor",
instruction="Verify the billing record and charge if missing.",
tools=[FunctionTool(charge_stripe)], # Also has charge access for gap-fill
)
parallel = ParallelAgent(
name="billing_and_audit",
sub_agents=[billing_agent, audit_agent],
)
What breaks: Both billing_agent and audit_agent run simultaneously. The billing agent fires a charge. The audit agent, seeing no confirmed charge in its own context (it has a separate invocation context from the billing agent), also fires a charge to "fill the gap." Two stripe.Charge.create calls land at Stripe with no idempotency key — two distinct charges for one billing cycle. The customer is billed twice.
The fix requires two changes: isolated vault keys per agent role so the audit agent physically cannot call the charge endpoint, and a per-invocation idempotency key so that even if both branches do call Stripe, the second call is deduplicated:
import os
import stripe
from google.adk.agents import ParallelAgent, LlmAgent
from google.adk.tools import FunctionTool, ToolContext
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
PROXY_URL = "https://proxy.keybrake.com"
def make_billing_tool(vault_key: str):
"""Return a charge tool scoped to a billing vault key."""
client = stripe.StripeClient(api_key=vault_key, base_url=PROXY_URL + "/stripe/")
def charge_stripe(customer_id: str, amount_cents: int, billing_period: str,
tool_context: ToolContext) -> dict:
"""Charge a Stripe customer for a specific billing period."""
# Stable idempotency key: session + customer + amount + period
idempotency_key = f"{tool_context.invocation_id}-{customer_id}-{amount_cents}-{billing_period}"
charge = client.charges.create(params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Subscription {billing_period}",
"idempotency_key": idempotency_key,
})
return {"charge_id": charge.id, "status": charge.status}
return FunctionTool(charge_stripe)
def make_audit_tool(vault_key: str):
"""Return an audit tool scoped to a read-only vault key."""
client = stripe.StripeClient(api_key=vault_key, base_url=PROXY_URL + "/stripe/")
def list_charges(customer_id: str, limit: int = 5) -> dict:
"""List recent charges for a customer (read-only)."""
charges = client.charges.list(params={"customer": customer_id, "limit": limit})
return {"charges": [{"id": c.id, "amount": c.amount, "status": c.status}
for c in charges.data]}
return FunctionTool(list_charges)
# Per-role vault keys from environment
BILLING_VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"] # allows POST /v1/charges only
AUDIT_VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_AUDIT"] # allows GET /v1/charges only
billing_agent = LlmAgent(
model="gemini-2.0-flash",
name="biller",
instruction="Charge the customer for their subscription using charge_stripe.",
tools=[make_billing_tool(BILLING_VAULT_KEY)],
)
audit_agent = LlmAgent(
model="gemini-2.0-flash",
name="auditor",
instruction="List and verify recent charges using list_charges. Do not create charges.",
tools=[make_audit_tool(AUDIT_VAULT_KEY)], # Read-only — cannot fire charges
)
parallel = ParallelAgent(
name="billing_and_audit",
sub_agents=[billing_agent, audit_agent],
)
What this fixes: The audit agent's vault key only permits GET /v1/charges — a charge attempt returns 403 from the proxy before it reaches Stripe. The billing agent's idempotency key is stable across any number of parallel executions for the same (invocation_id, customer, amount, period) tuple — Stripe deduplicates and returns the original charge object. Even if both branches somehow call the billing endpoint, the customer is charged once.
ADK's ToolContext object provides invocation_id — a unique identifier for the current agent invocation. This is the right granularity for idempotency keys: it's unique per logical agent run (not per session, which spans multiple turns) and stable across retries within that run.
Failure mode 2: LoopAgent retries Stripe without idempotency keys
ADK's LoopAgent runs a sub-agent repeatedly until it emits an escalation or a configured exit condition is met. This pattern is useful for iterative workflows — "keep trying until the payment succeeds" — but it creates a billing loop when the agent doesn't use idempotency keys:
from google.adk.agents import LoopAgent, LlmAgent
from google.adk.tools import FunctionTool
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def charge_with_retry(customer_id: str, amount_cents: int) -> dict:
"""Attempt a Stripe charge. Returns status so the loop can decide to continue."""
try:
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description="Subscription charge",
# No idempotency_key — each call is a new charge
)
return {"status": "succeeded", "charge_id": charge.id}
except stripe.error.CardError as e:
return {"status": "failed", "error": str(e)}
except stripe.error.APIConnectionError:
return {"status": "network_error"} # Loop will retry
retry_agent = LlmAgent(
model="gemini-2.0-flash",
name="retry_biller",
instruction=(
"Call charge_with_retry. If the result is 'succeeded', escalate with 'DONE'. "
"If 'failed', escalate with 'GIVE_UP'. If 'network_error', try again."
),
tools=[FunctionTool(charge_with_retry)],
)
loop = LoopAgent(
name="billing_loop",
sub_agents=[retry_agent],
max_iterations=5,
)
What breaks: On a network_error, Stripe may have accepted the charge but the response timed out before it arrived. The LoopAgent re-runs retry_biller, which calls charge_with_retry again — a new stripe.Charge.create call without an idempotency key. Stripe sees it as a fresh request and creates another charge. After 5 iterations on intermittent network errors, you have up to 5 charges for one billing cycle. The customer's card is charged multiple times; your Stripe dashboard shows 5 separate charges.
The fix is to generate the idempotency key before the loop starts and pass it into each iteration. The key must be stable across all loop iterations for the same logical operation:
import os
import uuid
import stripe
from google.adk.agents import LoopAgent, LlmAgent
from google.adk.tools import FunctionTool, ToolContext
PROXY_URL = "https://proxy.keybrake.com"
VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]
client = stripe.StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL + "/stripe/")
def charge_with_retry(customer_id: str, amount_cents: int,
billing_period: str, tool_context: ToolContext) -> dict:
"""
Attempt a Stripe charge. Idempotency key is stable for this
(invocation_id, customer, amount, period) — safe to retry any number of times.
"""
# invocation_id is the same for all iterations of the LoopAgent
idempotency_key = f"loop-{tool_context.invocation_id}-{customer_id}-{amount_cents}-{billing_period}"
try:
charge = client.charges.create(
params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Subscription {billing_period}",
"idempotency_key": idempotency_key,
}
)
return {"status": "succeeded", "charge_id": charge.id}
except stripe.error.CardError as e:
return {"status": "failed", "error": e.user_message}
except (stripe.error.APIConnectionError, stripe.error.Timeout):
# Safe to retry — Stripe deduplicates on idempotency_key
return {"status": "network_error", "retry": True}
retry_agent = LlmAgent(
model="gemini-2.0-flash",
name="retry_biller",
instruction=(
"Call charge_with_retry with the customer_id, amount_cents, and billing_period "
"from the task. If status is 'succeeded', escalate with 'DONE'. "
"If 'failed', escalate with 'GIVE_UP'. If 'network_error', try again."
),
tools=[FunctionTool(charge_with_retry)],
)
loop = LoopAgent(
name="billing_loop",
sub_agents=[retry_agent],
max_iterations=5,
)
What this fixes: ADK's LoopAgent preserves the same invocation_id across all iterations of a loop run. The idempotency key is therefore identical on every retry — Stripe recognizes the duplicate and returns the original charge object without creating a new one. Five loop iterations for the same charge cycle correctly produce one charge, not five.
Note that invocation_id changes each time runner.run() is called. If your outer system calls runner.run() multiple times for the same logical billing operation (e.g., a cron job that retries failed sessions), you'll need an external idempotency key — not one derived from invocation_id. In that case, pass a billing_operation_id from your caller into the tool as an argument and use it as the key prefix.
Failure mode 3: Session state persists billing context across resumed runs
ADK's session service stores the full conversation history — including tool call results — in session state. When a session is resumed (same session_id, new runner.run() call), ADK reconstructs the conversation history from the session store and gives the LLM the prior context. This is intentional and useful for multi-turn workflows. It creates a billing risk when prior tool results include charge IDs, customer data, or billing instructions that the LLM can act on again.
from google.adk.sessions import DatabaseSessionService
from google.adk.runners import Runner
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool
from google.genai import types
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def charge_stripe(customer_id: str, amount_cents: int, description: str) -> dict:
charge = stripe.Charge.create(
amount=amount_cents, currency="usd",
customer=customer_id, description=description,
)
return {"charge_id": charge.id, "status": charge.status}
agent = LlmAgent(
model="gemini-2.0-flash",
name="billing_agent",
instruction="You handle Stripe billing.",
tools=[FunctionTool(charge_stripe)],
)
# Persistent session — history survives process restarts
session_service = DatabaseSessionService(db_url="sqlite:///./sessions.db")
runner = Runner(agent=agent, app_name="billing", session_service=session_service)
# Turn 1: charge fires correctly
runner.run(
user_id="u1", session_id="billing-2026-06",
new_message=types.Content(role="user", parts=[
types.Part(text="Charge cus_Abc123 $29 for June subscription")
]),
)
# ... later, in a new process or after a crash ...
# Turn 2: "retry" with stale session history
runner.run(
user_id="u1", session_id="billing-2026-06", # same session_id
new_message=types.Content(role="user", parts=[
types.Part(text="Retry the last billing operation — the confirmation didn't come through")
]),
)
What breaks: The second runner.run() call resumes the same session. ADK reconstructs the conversation history including the first turn's tool call to charge_stripe. The user's message "retry the last billing operation" is ambiguous — the LLM sees a prior successful charge in context and a request to retry it. It calls charge_stripe again with the same customer and amount. No idempotency key means Stripe treats it as a new request. The customer is billed twice for June.
There are two layers to the fix: use content-hash idempotency keys so retries within any session are always safe, and expire billing sessions after the operation completes so stale history can't be resumed into a new billing cycle:
import os
import hashlib
import json
import stripe
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool, ToolContext
from google.adk.sessions import DatabaseSessionService
from google.adk.runners import Runner
from google.genai import types
PROXY_URL = "https://proxy.keybrake.com"
VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]
client = stripe.StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL + "/stripe/")
def charge_stripe(customer_id: str, amount_cents: int,
billing_period: str, tool_context: ToolContext) -> dict:
"""
Charge a Stripe customer. Idempotency key is a content hash of the
(customer, amount, period) triple — stable across any session or process restart
for the same logical billing operation.
"""
operation = {"customer": customer_id, "amount": amount_cents, "period": billing_period}
content_hash = hashlib.sha256(
json.dumps(operation, sort_keys=True).encode()
).hexdigest()[:32]
idempotency_key = f"adk-{content_hash}"
# Check Stripe's idempotency — if this key was already used, Stripe returns
# the original charge object and we log it as a deduplication event
try:
charge = client.charges.create(params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Subscription {billing_period}",
"idempotency_key": idempotency_key,
})
return {
"charge_id": charge.id,
"status": charge.status,
"deduplicated": charge.get("metadata", {}).get("idempotency_replayed", False),
}
except stripe.error.IdempotencyError as e:
# Same idempotency key with different parameters — surface this as an error
return {"status": "idempotency_conflict", "error": str(e)}
agent = LlmAgent(
model="gemini-2.0-flash",
name="billing_agent",
instruction=(
"You handle Stripe billing. When asked to 'retry' or 'reprocess' a charge, "
"always confirm with the user what billing_period they mean before calling "
"charge_stripe. Never infer billing_period from conversation history."
),
tools=[FunctionTool(charge_stripe)],
)
session_service = DatabaseSessionService(db_url="sqlite:///./sessions.db")
runner = Runner(agent=agent, app_name="billing", session_service=session_service)
What this fixes: The content-hash idempotency key is the same regardless of which session or process issues the charge — adk-{sha256(customer+amount+period)[:32]} will always be identical for the same logical billing operation. Stripe deduplicates the retry and returns the original charge. The updated system prompt instructs the model to always confirm the billing period with the user before calling charge_stripe, preventing stale context from being silently replayed.
The second layer: expire billing sessions explicitly. After a charge succeeds, delete the session or create a new session ID for the next billing cycle. ADK's session_service.delete_session() clears the history so the next billing turn starts fresh. This prevents the "billing-2026-06" session from being reused for July billing, where the June charge in history could be ambiguously re-invoked.
The proxy layer: closing all three at once
Idempotency keys and per-role vault keys solve the correctness problems within a single ADK run. They don't address the operational governance questions: what happens when an agent exceeds a daily spend budget, calls a Stripe endpoint it shouldn't be allowed to touch, or needs to be killed mid-workflow? For those, you need a policy layer outside the agent process.
Keybrake works as a drop-in proxy between your ADK agents and Stripe. You issue a vault_key_xxx per agent role, attach a policy (daily USD cap, allowed endpoints, expiry), and point stripe.StripeClient at the proxy URL. The real Stripe key never leaves the proxy:
import os
import stripe
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool, ToolContext
import hashlib, json
PROXY_URL = "https://proxy.keybrake.com"
VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]
# One-line proxy override — no other code changes required
client = stripe.StripeClient(
api_key=VAULT_KEY,
base_url=PROXY_URL + "/stripe/",
)
def charge_stripe(customer_id: str, amount_cents: int,
billing_period: str, tool_context: ToolContext) -> dict:
"""Charge a customer via the Keybrake proxy."""
op = {"c": customer_id, "a": amount_cents, "p": billing_period}
idempotency_key = "adk-" + hashlib.sha256(
json.dumps(op, sort_keys=True).encode()
).hexdigest()[:32]
charge = client.charges.create(params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Subscription {billing_period}",
"idempotency_key": idempotency_key,
})
return {"charge_id": charge.id, "status": charge.status}
billing_agent = LlmAgent(
model="gemini-2.0-flash",
name="billing_agent",
instruction="Handle Stripe billing for agent workflows.",
tools=[FunctionTool(charge_stripe)],
)
The proxy reads the real Stripe key from its database, enforces the vault key's policy (daily cap, endpoint allowlist), logs the call to the audit table, and forwards to Stripe. The stripe.StripeClient interface is unchanged — no other code in your ADK workflow needs to know about the proxy.
Comparison: raw key vs. restricted key vs. vault key
| Control | Raw sk_live_ |
Restricted key | Vault key (proxy) |
|---|---|---|---|
| Endpoint allowlist | All endpoints | Static, set in Stripe dashboard | Per-vault-key, enforced at proxy |
| Daily spend cap | None | None | USD cap per vault key per day |
| Per-agent isolation | Shared across all ADK sub-agents | Shared across all ADK sub-agents | One vault key per agent role |
| Retry dedup | Requires manual idempotency keys | Requires manual idempotency keys | Idempotency key enforced at proxy layer |
| Session replay guard | None | None | Daily cap stops runaway resumed sessions |
| Audit trail | Stripe Dashboard only | Stripe Dashboard only | Per-call log with vault key, UA, timestamp |
| Kill switch | Rotate key (breaks all agents) | Disable in dashboard (all users) | Revoke one vault key, others unaffected |
pytest enforcement: verify before you ship
These properties are testable. A test suite that enforces them before every deploy is cheaper than a duplicate-charge incident in production:
import pytest
import stripe
import hashlib
import json
import os
from unittest.mock import patch, MagicMock
PROXY_URL = "https://proxy.keybrake.com"
VAULT_KEY_BILLING = os.environ.get("KEYBRAKE_VAULT_KEY_BILLING", "vk_test_billing")
VAULT_KEY_AUDIT = os.environ.get("KEYBRAKE_VAULT_KEY_AUDIT", "vk_test_audit")
@pytest.fixture
def mock_stripe_client():
with patch("stripe.StripeClient") as m:
client = MagicMock()
m.return_value = client
yield client
def test_vault_keys_not_live():
"""Neither vault key should be a raw Stripe secret key."""
assert not VAULT_KEY_BILLING.startswith("sk_live_"), \
"Live Stripe key used as vault key for billing — use Keybrake vault key"
assert not VAULT_KEY_AUDIT.startswith("sk_live_"), \
"Live Stripe key used as vault key for audit — use Keybrake vault key"
def test_stripe_client_points_at_proxy(mock_stripe_client):
"""StripeClient must be initialized with the proxy base_url."""
stripe.StripeClient(api_key=VAULT_KEY_BILLING, base_url=PROXY_URL + "/stripe/")
call_kwargs = str(stripe.StripeClient.call_args)
assert PROXY_URL in call_kwargs, \
f"Expected proxy URL {PROXY_URL} in StripeClient init — found: {call_kwargs}"
def test_idempotency_key_is_content_hash():
"""Idempotency key must be deterministic for (customer, amount, period)."""
def make_key(customer_id, amount_cents, billing_period):
op = {"c": customer_id, "a": amount_cents, "p": billing_period}
return "adk-" + hashlib.sha256(json.dumps(op, sort_keys=True).encode()).hexdigest()[:32]
k1 = make_key("cus_Abc123", 2900, "2026-07")
k2 = make_key("cus_Abc123", 2900, "2026-07")
k3 = make_key("cus_Abc123", 2900, "2026-08")
assert k1 == k2, "Idempotency key is non-deterministic — retries will create duplicate charges"
assert k1 != k3, "Different billing periods must produce different idempotency keys"
def test_loop_retries_safe_with_idempotency(mock_stripe_client):
"""Simulated LoopAgent retry: same key on second call must not create a second charge."""
def make_key(customer_id, amount_cents, billing_period):
op = {"c": customer_id, "a": amount_cents, "p": billing_period}
return "adk-" + hashlib.sha256(json.dumps(op, sort_keys=True).encode()).hexdigest()[:32]
key_call1 = make_key("cus_Abc123", 2900, "2026-07")
key_call2 = make_key("cus_Abc123", 2900, "2026-07") # Second loop iteration
assert key_call1 == key_call2, \
"Loop iterations produce different idempotency keys — duplicate charge risk"
def test_audit_vault_key_cannot_charge(mock_stripe_client):
"""Audit vault key must be rejected when used on the charge endpoint."""
client = stripe.StripeClient(
api_key=VAULT_KEY_AUDIT,
base_url=PROXY_URL + "/stripe/",
)
mock_stripe_client.charges.create.side_effect = stripe.error.PermissionError(
"This vault key is not authorized for POST /v1/charges",
code="permission_denied",
)
with pytest.raises(stripe.error.PermissionError):
mock_stripe_client.charges.create(params={
"amount": 2900, "currency": "usd",
"customer": "cus_Abc123", "description": "Subscription",
})
Gap analysis: what this doesn't cover
Even with idempotency keys, per-role vault keys, and a spend-cap proxy, some failure modes remain:
- Multi-model ParallelAgent with shared external state. If two parallel ADK sub-agents both read and write to a shared database record (e.g., an "order status" row) before deciding whether to charge, both can read "unpaid" simultaneously and both fire charges. The idempotency key deduplicates the Stripe calls — but the shared state still shows two charge attempts. Your application layer needs a distributed lock or a database-level compare-and-swap before the charge tool is called.
- ADK tool exceptions surface differently than return values. If a tool raises an uncaught exception (rather than returning an error dict), ADK propagates it to the LLM as a tool failure. The LLM may retry the tool in the same invocation without your idempotency key logic running correctly, depending on how the exception is raised. Always catch Stripe exceptions inside your tool and return structured error dicts — never let Stripe errors propagate as Python exceptions in an ADK tool.
- Gemini function calling parallel tool use. Gemini 2.0 models can emit multiple parallel function calls in a single response turn. If your prompt elicits both a
charge_stripeand asend_receiptin the same response, both fire before either result is returned. If the charge fires twice in one turn (e.g., two different prompt clauses both trigger the tool), your idempotency key scope matters —invocation_idalone isn't sufficient if the model calls the same tool twice in one turn. Include the full argument set in the key hash. - LoopAgent exit condition race with in-flight Stripe requests. If your exit condition is "charge succeeded" and a network timeout causes the charge to appear to fail, the loop iterates — but the charge may already be processing at Stripe. When the retry lands with the same idempotency key, Stripe returns the original charge with status
pending. Your tool must handlependingas a terminal state (do not retry) rather than as a failure that triggers another loop iteration.
FAQ
How does ADK's LoopAgent handle the invocation_id across iterations?
ADK preserves the same invocation_id across all iterations within a single runner.run() call. Each iteration of the LoopAgent uses the same invocation context, so an idempotency key based on tool_context.invocation_id is stable across all loop iterations. If you call runner.run() a second time (e.g., from an outer retry in your application code), a new invocation_id is issued — at that point, you should use a content-hash key derived from the billing operation parameters instead.
Can I use ADK's built-in session state to track whether a charge already fired?
Yes, as a complement to idempotency keys. You can write a charge_id to tool_context.state["last_charge"] and have your tool check state before calling Stripe. This gives the LLM a fact-check mechanism: "has this operation already been charged?" before it calls charge_stripe. However, don't use state as the sole deduplication mechanism — state can be cleared, migrated, or corrupted, while Stripe's idempotency key is authoritative at the payment layer.
How do I scope vault keys per ADK sub-agent in a SequentialAgent?
Create a separate vault key for each agent role and pass each key into the corresponding tool factory via a closure. For a SequentialAgent where the first agent validates customer eligibility and the second agent charges, the validator gets a vault key that allows only GET /v1/customers, and the biller gets a key that allows only POST /v1/charges. Even if the sequential order is disrupted (e.g., the validator is bypassed), the biller's key is the only one that can fire charges.
Does Keybrake proxy work with ADK's streaming mode?
Yes. ADK streaming is at the LLM layer (token-by-token output from Gemini), not at the tool layer. Your FunctionTool executes synchronously as a regular HTTP call to proxy.keybrake.com/stripe/v1/charges — the proxy intercepts, enforces policy, and forwards to Stripe over a standard HTTPS connection. The streaming output of the LLM is unaffected by the proxy.
What happens to the proxy if keybrake.com goes down?
If the Keybrake proxy is unreachable, your stripe.StripeClient will raise a stripe.error.APIConnectionError — the same error you'd get from a network failure talking directly to Stripe. Your tool's exception handler catches it and returns an error status. This means the proxy introduces one additional dependency in your agent's network path. For production workloads that need 99.9%+ availability, run the proxy as a sidecar container in your own infrastructure so it's on the same network as your agents.
How do I handle the LoopAgent exit condition when a charge returns "pending"?
Treat pending as a terminal success state, not a failure state that triggers another loop iteration. A Stripe charge in pending status has been accepted — the funds are being processed. Your tool should return {"status": "pending", "charge_id": charge.id} and your agent's instruction should escalate with "DONE" on any non-failed status (both succeeded and pending). Retrying a pending charge creates a second charge — Stripe's idempotency key catches duplicates only within a 24-hour window.
Put the brakes on your ADK agent's Stripe key
Per-role vault keys, daily spend caps, endpoint allowlists, and a one-click kill switch — live now at proxy.keybrake.com.