Agent Governance
Smolagents Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
HuggingFace's smolagents makes it easy to wire Stripe into an agent: register a Python function as a @tool, hand it to a ToolCallingAgent, and the model decides when to charge. The risk emerges with CodeAgent — because CodeAgent doesn't call your tool, it generates arbitrary Python code and runs it, and if stripe is reachable from the sandbox, the generated code can call stripe.Charge.create() directly with no governance layer in the path.
This post covers three failure modes specific to smolagents' architecture — CodeAgent direct Stripe execution, ToolCallingAgent retry without idempotency keys, and ManagedAgent billing context leakage — 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 smolagents Stripe pattern
smolagents offers two agent types for tool-based workflows. ToolCallingAgent uses structured function-call outputs (like OpenAI function calling); CodeAgent generates executable Python code instead. A single-agent billing workflow with ToolCallingAgent looks like this:
from smolagents import ToolCallingAgent, tool, LiteLLMModel
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # sk_live_...
@tool
def charge_stripe(customer_id: str, amount_cents: int, description: str) -> dict:
"""
Charge a Stripe customer and return the charge details.
Args:
customer_id: Stripe customer ID (e.g. cus_Abc123)
amount_cents: Amount in cents (e.g. 2900 for $29)
description: Human-readable charge description
Returns:
dict with charge_id and status
"""
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=description,
)
return {"charge_id": charge.id, "status": charge.status}
agent = ToolCallingAgent(
tools=[charge_stripe],
model=LiteLLMModel("gpt-4o"),
)
result = agent.run("Charge customer cus_Abc123 $29 for the Hobby plan upgrade")
This works correctly under normal conditions. Three distinct failure modes appear when you switch to CodeAgent, when the LLM retries on tool errors, or when you compose agents using ManagedAgent.
Failure mode 1: CodeAgent executes Stripe directly in generated code
smolagents' CodeAgent doesn't call tools as structured function invocations — it generates a block of Python code and executes it in a sandboxed interpreter (LocalPythonInterpreter). The sandbox controls what the code can import via additional_authorized_imports. If stripe is in that list, the LLM can write raw Stripe API calls directly in the generated code:
from smolagents import CodeAgent, HfApiModel
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
# CodeAgent with stripe available in the sandbox
agent = CodeAgent(
tools=[], # No formal tools needed — the LLM just imports stripe directly
model=HfApiModel("Qwen/Qwen2.5-Coder-32B-Instruct"),
additional_authorized_imports=["stripe", "os"], # stripe exposed in sandbox
)
result = agent.run("Charge customer cus_Abc123 $29 for the Hobby plan upgrade")
# The LLM generates and executes code like:
# import stripe, os
# stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
# charge = stripe.Charge.create(
# amount=2900, currency="usd",
# customer="cus_Abc123", description="Hobby plan upgrade"
# )
# final_answer(f"Charged: {charge.id}")
What breaks: The generated code calls stripe.Charge.create() with no idempotency key and no spend-cap proxy in the path. If the code execution raises a syntax error or the interpreter gets an unexpected result, the CodeAgent may generate revised code and run it again — a second raw Stripe call. Because the code is generated fresh each iteration, each execution is a new charge. With max_steps=10, a buggy generation loop could fire multiple charges before the agent gives up.
The fix has two parts: remove stripe from authorized imports entirely, and register a governed @tool that goes through the proxy layer. CodeAgent can call registered tools from its generated code using the tool_name(args) syntax — it doesn't need direct library access:
from smolagents import CodeAgent, tool, HfApiModel
import stripe, os, hashlib
PROXY_URL = "https://proxy.keybrake.com"
VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"] # POST /v1/charges only
@tool
def charge_stripe(customer_id: str, amount_cents: int, billing_period: str) -> dict:
"""
Charge a Stripe customer via the Keybrake spend-cap proxy. Safe to call
multiple times with the same arguments — produces one charge.
Args:
customer_id: Stripe customer ID (e.g. cus_Abc123)
amount_cents: Amount in cents (e.g. 2900 for $29)
billing_period: Unique period string (e.g. "2026-06") used for dedup
Returns:
dict with charge_id and status, or error key on failure
"""
idempotency_key = hashlib.sha256(
f"{customer_id}:{amount_cents}:{billing_period}".encode()
).hexdigest()[:32]
client = stripe.StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL + "/stripe/")
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}
except stripe.error.StripeError as e:
return {"error": str(e), "idempotency_key": idempotency_key}
agent = CodeAgent(
tools=[charge_stripe],
model=HfApiModel("Qwen/Qwen2.5-Coder-32B-Instruct"),
# stripe NOT in authorized imports — model cannot call it directly
additional_authorized_imports=[],
)
What this fixes: With no stripe import allowed in the sandbox, the LLM cannot write raw stripe.Charge.create() calls. It can only call charge_stripe(customer_id, amount_cents, billing_period) via the registered tool interface. Every Stripe call routes through the proxy (spend cap enforced, audit logged, kill switch available), and the content-hash idempotency key ensures that any number of code re-executions for the same billing period produces exactly one charge.
This applies to any external API, not just Stripe. Any library that can spend money or send messages should never appear in additional_authorized_imports. Wrap it in a governed @tool instead.
Failure mode 2: ToolCallingAgent retries tools on errors without idempotency keys
smolagents' ToolCallingAgent catches exceptions from tool calls and adds them to the agent's memory as error steps. The LLM sees the error and is prompted to retry. If a Stripe charge raised APIConnectionError — meaning the request was sent but the response timed out before it arrived — the tool raises an exception, the agent logs it as a failed step, and the LLM calls the tool again:
from smolagents import ToolCallingAgent, tool, LiteLLMModel
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
@tool
def charge_stripe(customer_id: str, amount_cents: int, description: str) -> dict:
"""
Charge a Stripe customer.
Args:
customer_id: Stripe customer ID
amount_cents: Amount in cents
description: Charge description
Returns:
dict with charge_id and status
"""
# No idempotency_key — each call is a new charge at Stripe
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=description,
)
return {"charge_id": charge.id, "status": charge.status}
agent = ToolCallingAgent(
tools=[charge_stripe],
model=LiteLLMModel("gpt-4o"),
max_steps=10,
)
What breaks: On a transient network error, Stripe may have accepted the charge but the response didn't arrive. The tool raises stripe.error.APIConnectionError, which propagates out of charge_stripe and is caught by the smolagents runtime. The error is added to agent memory, and the LLM is prompted to fix the issue and try again. The retry fires a fresh stripe.Charge.create call without an idempotency key — Stripe treats it as a new request and creates another charge. With max_steps=10, a flaky connection could produce up to five charges before the agent exhausts its step budget.
Two fixes work together: always catch Stripe errors inside the tool and return a structured dict (never let exceptions propagate from a @tool), and use a content-hash idempotency key so any number of retries for the same operation produce one charge at Stripe:
from smolagents import ToolCallingAgent, tool, LiteLLMModel
import stripe, os, hashlib
PROXY_URL = "https://proxy.keybrake.com"
VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]
@tool
def charge_stripe(customer_id: str, amount_cents: int, billing_period: str) -> dict:
"""
Charge a Stripe customer for a billing period. Safe to call multiple times
with the same arguments — idempotency key ensures one charge.
Args:
customer_id: Stripe customer ID (e.g. cus_Abc123)
amount_cents: Amount in cents (e.g. 2900 for $29)
billing_period: Unique period string (e.g. "2026-06")
Returns:
dict with charge_id and status, or error key on failure
"""
idempotency_key = hashlib.sha256(
f"{customer_id}:{amount_cents}:{billing_period}".encode()
).hexdigest()[:32]
client = stripe.StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL + "/stripe/")
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}
except stripe.error.CardError as e:
# Card declined — do not retry
return {"error": "card_declined", "message": e.user_message, "retry": False}
except (stripe.error.APIConnectionError, stripe.error.Timeout):
# Safe to retry — Stripe deduplicates on idempotency_key
return {"error": "network_error", "retry": True, "idempotency_key": idempotency_key}
except stripe.error.StripeError as e:
return {"error": str(e), "retry": False}
agent = ToolCallingAgent(
tools=[charge_stripe],
model=LiteLLMModel("gpt-4o"),
max_steps=6, # Tighter cap — prevents runaway retry loops
)
What this fixes: The tool never raises an exception — it always returns a dict the agent can reason about. The retry: True field tells the LLM it's safe to try again; retry: False on card decline tells it to stop. Because the idempotency key is derived from (customer_id, amount_cents, billing_period), it's identical on every retry for the same billing operation — Stripe deduplicates and returns the original charge object. The customer is charged once regardless of how many network errors occur.
The billing_period argument is important: it scopes the idempotency key to a specific billing cycle. Without it, charging the same customer $29 in July would reuse the June idempotency key and return the June charge object — a billing bug that looks like a successful charge but produces no revenue.
Failure mode 3: ManagedAgent delegation carries billing context to sub-agents
smolagents' ManagedAgent wraps a sub-agent so it can be used as a tool by a manager agent. The manager writes a task string describing what it needs, and the sub-agent receives that string and acts on it. This pattern is useful for specialization — a billing sub-agent, a fulfillment sub-agent, each with their own tools. The risk is that both the manager and the sub-agent might have Stripe access, and billing context in the task string can trigger charges at both levels:
from smolagents import CodeAgent, ToolCallingAgent, ManagedAgent, tool, LiteLLMModel
import stripe, os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
@tool
def charge_stripe(customer_id: str, amount_cents: int, description: str) -> dict:
"""Charge a Stripe customer."""
charge = stripe.Charge.create(
amount=amount_cents, currency="usd",
customer=customer_id, description=description,
)
return {"charge_id": charge.id}
@tool
def list_charges(customer_id: str) -> dict:
"""List recent charges for a customer."""
charges = stripe.Charge.list(customer=customer_id, limit=5)
return {"charges": [{"id": c.id, "amount": c.amount} for c in charges.data]}
# Sub-agent: handles billing
billing_subagent = ToolCallingAgent(
tools=[charge_stripe], # has charge access
model=LiteLLMModel("gpt-4o-mini"),
)
# Manager: also has charge_stripe "for verification"
manager = CodeAgent(
tools=[
charge_stripe, # manager can also charge — duplicate risk
list_charges,
ManagedAgent(
agent=billing_subagent,
name="billing_agent",
description=(
"Handles Stripe billing. Pass the customer ID, amount, and description. "
"Returns charge ID when complete."
),
),
],
model=LiteLLMModel("gpt-4o"),
)
What breaks: The manager calls billing_agent(task="Charge cus_Abc123 $29 for June plan"). The sub-agent calls charge_stripe and returns the charge ID. The manager receives the charge ID and, in its next code generation step, may call charge_stripe(customer_id="cus_Abc123", amount_cents=2900, ...) directly — "to confirm the charge landed correctly." Both calls use bare Stripe keys with no idempotency, so Stripe creates two charges. The sub-agent's charge and the manager's "verification" charge are independent transactions on the customer's card.
The fix uses strict role separation at the vault key level: the manager gets a read-only audit key (only GET /v1/charges allowed), and the billing sub-agent gets a charge key (only POST /v1/charges allowed). Even if the manager generates code that calls a charge function, the proxy rejects it with a 403 before it reaches Stripe:
from smolagents import CodeAgent, ToolCallingAgent, ManagedAgent, tool, LiteLLMModel
import stripe, os, hashlib
PROXY_URL = "https://proxy.keybrake.com"
BILLING_VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"] # POST /v1/charges only
AUDIT_VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY_AUDIT"] # GET /v1/charges only
def make_charge_tool(vault_key: str):
client = stripe.StripeClient(api_key=vault_key, base_url=PROXY_URL + "/stripe/")
@tool
def charge_stripe(customer_id: str, amount_cents: int, billing_period: str) -> dict:
"""
Charge a Stripe customer for a billing period. Idempotent.
Args:
customer_id: Stripe customer ID
amount_cents: Amount in cents
billing_period: Unique period string (e.g. "2026-06")
Returns:
dict with charge_id and status, or error key on failure
"""
key = hashlib.sha256(
f"{customer_id}:{amount_cents}:{billing_period}".encode()
).hexdigest()[:32]
try:
charge = client.charges.create(params={
"amount": amount_cents, "currency": "usd", "customer": customer_id,
"description": f"Subscription {billing_period}", "idempotency_key": key,
})
return {"charge_id": charge.id, "status": charge.status}
except stripe.error.StripeError as e:
return {"error": str(e), "idempotency_key": key}
return charge_stripe
def make_list_tool(vault_key: str):
client = stripe.StripeClient(api_key=vault_key, base_url=PROXY_URL + "/stripe/")
@tool
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 list_charges
# Billing sub-agent: charge tool with billing vault key
billing_subagent = ToolCallingAgent(
tools=[make_charge_tool(BILLING_VAULT_KEY)],
model=LiteLLMModel("gpt-4o-mini"),
)
# Manager: list tool with audit vault key only — POST /v1/charges → 403 from proxy
manager = CodeAgent(
tools=[
make_list_tool(AUDIT_VAULT_KEY),
ManagedAgent(
agent=billing_subagent,
name="billing_agent",
description=(
"Handles Stripe billing. Pass customer_id, amount_cents, billing_period. "
"Returns charge_id when complete."
),
),
],
model=LiteLLMModel("gpt-4o"),
)
What this fixes: The manager has no tool that can write charges — list_charges uses an audit vault key that the proxy allows only on GET /v1/charges. If the manager's generated code attempts to charge directly, the proxy returns 403 before the request reaches Stripe. The billing sub-agent's vault key allows charges, but its idempotency key is content-hashed — identical retries from the task description produce one charge. Role separation at the vault key level is the layer that eliminates the duplicate charge risk, not the system prompt.
Comparison: raw key vs restricted key vs vault key
| Property | Raw sk_live_ key |
Restricted Stripe key | Vault key (Keybrake proxy) |
|---|---|---|---|
| Endpoint allowlist | No — full API access | Partial — Stripe-enforced resource set | Yes — per-role allowlist, proxy-enforced |
| Daily spend cap | No | No | Yes — configurable per vault key |
| Per-agent isolation | No — all agents share one key | No — still shared | Yes — one vault key per agent role |
| CodeAgent sandbox guard | No — leaked into sandbox if imported | No — same leak risk | Yes — tool wraps proxy, stripe never imported |
| Retry deduplication | No — each call is a new charge | No — application-level problem | Yes — with idempotency key in tool closure |
| Audit trail | Stripe Dashboard only | Stripe Dashboard only | Yes — per-call log at proxy layer |
| Kill switch | Rotate key (affects all agents) | Rotate key (affects all agents) | Revoke vault key (scoped to one agent) |
pytest enforcement suite
import pytest, os, hashlib, stripe
PROXY_URL = "https://proxy.keybrake.com"
BILLING_KEY = os.environ.get("KEYBRAKE_VAULT_KEY_BILLING", "")
AUDIT_KEY = os.environ.get("KEYBRAKE_VAULT_KEY_AUDIT", "")
def test_vault_key_not_live():
"""Billing vault key must never be a raw Stripe live key."""
assert not BILLING_KEY.startswith("sk_live_"), (
"KEYBRAKE_VAULT_KEY_BILLING must be a vault key, not a raw Stripe live key"
)
def test_stripe_client_uses_proxy():
"""StripeClient must route through the Keybrake proxy, not api.stripe.com."""
client = stripe.StripeClient(api_key=BILLING_KEY, base_url=PROXY_URL + "/stripe/")
assert client.base_url.startswith(PROXY_URL), (
"StripeClient base_url must point at proxy.keybrake.com"
)
def test_charge_tool_returns_dict_not_raises(monkeypatch):
"""charge_stripe must return a dict on Stripe errors — never raise."""
from product_api import charge_stripe # import your @tool function
def mock_create(**kwargs):
raise stripe.error.APIConnectionError("timeout")
monkeypatch.setattr(stripe.Charge, "create", mock_create)
result = charge_stripe("cus_test", 2900, "2026-06")
assert "error" in result, "charge_stripe must return error dict, not raise"
def test_idempotency_key_is_deterministic():
"""Same (customer, amount, period) must always produce the same idempotency key."""
def make_key(customer_id, amount_cents, billing_period):
return hashlib.sha256(
f"{customer_id}:{amount_cents}:{billing_period}".encode()
).hexdigest()[:32]
key1 = make_key("cus_Abc123", 2900, "2026-06")
key2 = make_key("cus_Abc123", 2900, "2026-06")
assert key1 == key2, "Idempotency key must be deterministic for same inputs"
def test_different_periods_produce_different_keys():
"""Different billing periods must produce different idempotency keys."""
def make_key(customer_id, amount_cents, billing_period):
return hashlib.sha256(
f"{customer_id}:{amount_cents}:{billing_period}".encode()
).hexdigest()[:32]
june_key = make_key("cus_Abc123", 2900, "2026-06")
july_key = make_key("cus_Abc123", 2900, "2026-07")
assert june_key != july_key, "Different billing periods must produce different idempotency keys"
Gap analysis
CodeAgent tool return values leaking Stripe objects. Even with stripe not in additional_authorized_imports, a tool that returns a stripe.Charge object instead of a plain dict can expose the object to the sandbox. The generated code may then call methods on the returned object — including creating related charges. Always return plain dicts from @tool functions that touch Stripe, never Stripe SDK objects.
ToolCallingAgent max_steps and retry budget. smolagents' step counter increments on every tool call, including retries. With max_steps=10 and a billing loop that retries 3 times per network error, you could exhaust 9 steps on charge attempts before the agent terminates. Set max_steps conservatively — 6 is reasonable for a billing workflow — and use content-hash idempotency keys so every retry slot maps to one Stripe dedup entry regardless of how many steps fire.
LiteLLMModel parallel function calls. Models like GPT-4o and Claude 3.5 Sonnet support parallel function calling — emitting multiple tool calls in one response. smolagents executes them sequentially, but if two charge_stripe calls are emitted with the same arguments and no idempotency key, both create new Stripe charges. With content-hash idempotency keys bound to (customer_id, amount_cents, billing_period), both calls produce the same idempotency key and Stripe deduplicates them to one charge.
ManagedAgent task string injection. The task string the manager writes becomes the sub-agent's first user message. If the task string contains ambiguous phrasing — "retry the last billing operation" or "the customer hasn't been charged yet" — the sub-agent may interpret prior context differently than intended and charge more than once. Require billing_period as a mandatory argument in the sub-agent's charge tool, and add a system prompt instruction: "Always require an explicit billing_period; never infer it from conversation history."
Frequently asked questions
Can I use smolagents' E2BSandbox instead of restricting authorized imports?
E2BSandbox runs generated code in a remote cloud sandbox (E2B) rather than a local interpreter. This reduces the risk of local filesystem or network access from the sandbox, but it doesn't prevent Stripe calls if stripe is installed in the sandbox image. The authorized imports restriction is still the right control — it prevents the model from even writing the import line. E2B and import restrictions are complementary, not alternatives.
Does ToolCallingAgent automatically retry on network errors?
smolagents doesn't have a built-in retry mechanism — it doesn't distinguish between "tool failed, retry" and "tool returned an error result." What happens is that the tool's exception propagates into the agent's step log, the LLM sees the error message in its context, and its next generation may produce another tool call for the same operation. This is LLM-level retry, not framework-level retry, which is why catching errors inside the tool and returning structured dicts (with a retry field) gives the LLM better information to make the right decision.
How do I pass a per-run key to a @tool without global state?
Use a factory function that captures the vault key in a closure — the same pattern shown in the make_charge_tool(vault_key) example above. The @tool decorator is applied inside the factory, so each call to make_charge_tool(key) produces a distinct tool with its own captured client. This is the standard pattern for parameterizing smolagents tools without global state.
Does the Keybrake proxy add significant latency to Stripe calls?
The proxy runs as a sidecar container on the same host, adding sub-millisecond network overhead. Stripe's median API latency is 150–250 ms; the proxy contribution is under 1 ms on a local network. For agent workflows where Stripe is one tool call among many, the proxy latency is unmeasurable in practice. Deploy the proxy in the same datacenter region as your agent workers to keep the overhead negligible.
Can I use the vault key pattern with smolagents' vision or multimodal tools?
Yes. The vault key pattern applies to any tool that calls an external API — Stripe, Twilio, SendGrid, and so on. Vision tools (WebSearchTool, image analysis tools) don't reach Stripe and don't need vault key scoping. The pattern is: any tool that can spend money or send messages to customers gets a vault key; tools that only read or process data don't need proxy routing.
How do I verify that my idempotency key is unique per billing period in production?
Add a test that generates keys for consecutive billing periods and asserts they differ — the fifth test in the pytest suite above covers this. In production, log the idempotency key alongside each charge attempt in your proxy audit log. If you see two log entries with the same key, the second one was a successful dedup; if you see two entries with different keys for the same customer and amount, check that billing_period is being passed correctly from the LLM.
Vault keys for smolagents Stripe workflows
Keybrake issues scoped vault keys for Stripe — per-agent endpoint allowlists, daily spend caps, and a per-call audit log. One line change from stripe.api_key to stripe.StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL+"/stripe/"). Proxy is live now.