Payment Skill
Payment processing and billing skill for the Robutler platform. This skill enforces billing policies up-front and finalizes charges when a request completes.
[!NOTE] For full x402 protocol support (HTTP endpoint payments, blockchain payments, automatic exchange), see PaymentSkillX402. PaymentSkill focuses on tool-level charging and basic token validation, while PaymentSkillX402 extends it with multi-scheme payments and automatic payment handling for HTTP APIs.
Key Features
- Payment token validation during
on_connection(returns 402 if required and missing) - LLM cost calculation via server-side
MODEL_PRICINGcatalog - Tool pricing via optional
@pricingdecorator (results logged tocontext.usageby the agent) - Final charging based on
context.usageatfinalize_connection - Optional async/sync
amount_calculatorto customize total charge - Transaction creation via Portal API
- Depends on
AuthSkillfor user identity propagation
Configuration
enable_billing(default: true)agent_pricing_percent(percent, e.g.,20for 20%)minimum_balance(USD required to proceed; 0 allows free trials without up-front token)robutler_api_url,robutler_api_key(server-to-portal calls)amount_calculator(optional): async or sync callable(llm_cost_usd, tool_cost_usd, agent_pricing_percent_percent) -> float- Default:
(llm + tool) * (1 + agent_pricing_percent_percent/100)
- Default:
Example: Add Payment Skill to an Agent
from webagents.agents import BaseAgent
from webagents.agents.skills.robutler.auth.skill import AuthSkill
from webagents.agents.skills.robutler.payments import PaymentSkill
agent = BaseAgent(
name="paid-agent",
model="openai/gpt-4o",
skills={
"auth": AuthSkill(), # Required dependency
"payments": PaymentSkill({
"enable_billing": True,
"agent_pricing_percent": 20, # percent
"minimum_balance": 1.0 # USD
})
}
)Tool Pricing with @pricing Decorator (optional)
The PaymentSkill provides a @pricing decorator to annotate tools with pricing metadata. Tools can also return
explicit usage objects and will be accounted from context.usage during finalize.
from webagents import tool
from webagents.agents.skills.robutler.payments import pricing, PricingInfo
@tool
@pricing(credits_per_call=0.05, reason="Database query")
async def query_database(sql: str) -> dict:
"""Query database - costs 0.05 credits per call"""
return {"results": [...]}
@tool
@pricing() # Dynamic pricing
async def analyze_data(data: str) -> tuple:
"""Analyze data with variable pricing based on complexity"""
complexity = len(data)
result = f"Analysis of {complexity} characters"
# Simple complexity-based pricing: 0.001 credits per character
credits = max(0.01, complexity * 0.001) # Minimum 0.01 credits
pricing_info = PricingInfo(
credits=credits,
reason=f"Data analysis of {complexity} chars",
metadata={"character_count": complexity, "rate_per_char": 0.001}
)
return result, pricing_infoPricing Options
- Fixed Pricing:
@pricing(credits_per_call=0.05)(0.05 credits per call) - Dynamic Pricing: Return
(result, PricingInfo(credits=0.15, ...)) - Conditional Pricing: Override base pricing in function logic
Cost Calculation
- LLM Costs: Computed from
_llm_usagecontext (input/output tokens × model pricing rates). Skipped whenis_byok: true. - Tool Costs: Read from tool billing metadata (
_billingon tool results), validated and capped by PaymentSkill'safter_toolhook. - Total: If
amount_calculatoris provided, its return value is used; otherwise(llm + tool) * (1 + agent_pricing_percent_percent/100)
Example: Validate a Payment Token
from webagents.agents.skills import Skill, tool
class PaymentOpsSkill(Skill):
def __init__(self):
super().__init__()
self.payment = self.agent.skills["payment"]
@tool
async def validate_token(self, token: str) -> str:
"""Validate a payment token"""
result = await self.payment.validate_payment_token(token)
return str(result)Hook Integration
The PaymentSkill uses UAMP lifecycle hooks for billing at every stage of a request:
on_connection: Validate payment token and check balance. Ifenable_billingand no token is provided whileminimum_balance > 0, a 402 error is raised and processing stops.before_llm_call: Reads_llm_capabilitiesfrom context (set by the LLM skill) to estimate maximum LLM cost and lock funds. The lock covers worst-case output at the model's pricing rates.after_llm_call: Reads_llm_usagefrom context (set by the LLM skill after streaming completes). Ifis_byok: true, skips LLM billing (the user paid their provider directly). Otherwise, calculates actual LLM cost and records it for settlement.before_tool/after_tool: Validates tool pricing metadata and caps billed amounts. Thecharge_typeis validated against a fixed enum andactualCostis capped at 10x the configuredperCallprice to prevent malicious tools from inflating charges.finalize_connection: Aggregate all LLM and tool costs, compute final amount with agent markup, and settle the payment token.
BYOK Billing Behavior
| Scenario | LLM Billing | Tool Billing | Agent Markup |
|---|---|---|---|
| Platform key (default) | Charged | Charged | Applied to LLM + tools |
BYOK (is_byok: true) | Skipped | Charged | Applied to tools only |
| Agent developer's key | Charged (agent markup covers cost) | Charged | Applied to LLM + tools |
Tool fees are always billed regardless of the key scenario. BYOK only exempts LLM inference costs.
Context Namespacing
The PaymentSkill stores data in the payments namespace of the request context:
from webagents.server.context.context_vars import get_context
context = get_context()
payments_data = getattr(context, 'payments', None)
payment_token = getattr(payments_data, 'payment_token', None) if payments_data else NoneUsage Tracking
All usage is centralized on context.usage by the agent:
- LLM usage records are appended after each completion (including streaming final usage chunk).
- Tool usage is appended when a priced tool returns
(result, usage_payload); the agent unwraps the result and storesusage_payloadas a{type: 'tool', pricing: {...}}record.
At finalize_connection, the Payment Skill sums LLM and tool costs from context.usage and performs the charge.
Advanced: amount_calculator
You can provide an async or sync amount_calculator to fully control the final charge amount:
async def my_amount_calculator(llm_cost_usd: float, tool_cost_usd: float, agent_pricing_percent_percent: float) -> float:
base = llm_cost_usd + tool_cost_usd
# Custom logic here (e.g., tiered discounts)
return base * (1 + agent_pricing_percent_percent/100)
payment = PaymentSkill({
"enable_billing": True,
"agent_pricing_percent": 15, # percent
"amount_calculator": my_amount_calculator,
})If omitted, the default formula is used: (llm + tool) * (1 + agent_pricing_percent/100).
Dependencies
- AuthSkill: Required for user identity headers (
X-Origin-User-ID,X-Peer-User-ID,X-Agent-Owner-User-ID). The Payment Skill reads them from the auth namespace on the context.
Implementation: robutler/agents/skills/robutler/payments/skill.py.
Error semantics (402)
- Missing token while
enable_billingandminimum_balance > 0➜ 402 Payment Required - Invalid or expired token ➜ 402 Payment Token Invalid
- Insufficient balance ➜ 402 Insufficient Balance
Finalize hooks still run for cleanup but perform no charge if no token/usage is present.
Transport-Agnostic Payments
Starting with V2.0, PaymentSkill extracts the payment token in a transport-agnostic manner.
The skill reads context.payment_token first (set by any transport), then falls back to HTTP
headers (X-Payment-Token, X-PAYMENT) and query parameters as a legacy path.
This means payment works identically over HTTP Completions, UAMP WebSocket, A2A, ACP, and
Realtime transports -- the transport is responsible for negotiating the token (e.g. via
payment.required / payment.submit events over UAMP, or a 402 response over HTTP), and the
payment skill only validates and charges.
Token extraction priority
context.payment_token-- set by the transport (UAMPsession.update, portalpayment.submit, etc.)- HTTP header --
X-Payment-TokenorX-PAYMENT(Completions, A2A) - Query parameter --
?payment_token=...(legacy)
PaymentTokenRequiredError
When billing is enabled and no token is found, the skill raises PaymentTokenRequiredError
(HTTP status 402). Each transport catches this and maps it to its protocol:
| Transport | Behavior |
|---|---|
| Completions | Returns 402 JSON before streaming (pre-flight check) |
| UAMP | Sends payment.required event, waits for payment.submit, retries, sends payment.accepted |
| A2A | Returns task.failed with code: "payment_required" and accepts array |
| ACP | Returns JSON-RPC error -32402 with payment data |
| Realtime | Sends payment.required event over audio WebSocket |
Example: UAMP inline payment negotiation
Client Agent (UAMP)
│ │
├─ input.text ───────────────►│
│ ├─ (skill raises PaymentTokenRequiredError)
│◄── payment.required ───────┤
│ │
├─ payment.submit ───────────►│ (token from facilitator)
│ ├─ (retry with context.payment_token)
│◄── response.delta ─────────┤
│◄── response.done ──────────┤
│◄── payment.accepted ───────┤