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
import { BaseAgent } from 'webagents';
import { AuthSkill } from 'webagents/skills/auth';
import { PaymentSkill } from 'webagents/skills/payments';
const agent = new BaseAgent({
name: 'paid-agent',
model: 'openai/gpt-4o',
skills: [
new AuthSkill(),
new PaymentSkill({
enableBilling: true,
minimumBalance: 1.0,
agentFee: 0.05,
}),
],
});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.
import { Skill, tool, pricing } from 'webagents';
class BillingSkill extends Skill {
readonly name = 'billing';
@tool({ description: 'Query database — costs 0.05 credits per call' })
@pricing({ creditsPerCall: 0.05, reason: 'Database query' })
async queryDatabase(params: { sql: string }): Promise<{ results: unknown[] }> {
return { results: [] };
}
@tool({ description: 'Analyze data with variable pricing based on complexity' })
@pricing()
async analyzeData(params: { data: string }): Promise<unknown> {
const complexity = params.data.length;
const credits = Math.max(0.01, complexity * 0.001);
return {
result: `Analysis of ${complexity} characters`,
_pricing: {
credits,
reason: `Data analysis of ${complexity} chars`,
metadata: { characterCount: complexity, ratePerChar: 0.001 },
},
};
}
}Pricing 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
import { Skill, tool } from 'webagents';
import type { PaymentSkill } from 'webagents/skills/payments';
class PaymentOpsSkill extends Skill {
readonly name = 'payment-ops';
@tool({ description: 'Validate a payment token' })
async validateToken(params: { token: string }): Promise<string> {
const payments = this.agent!.skills.find(
(s) => s.name === 'payments',
) as PaymentSkill;
const result = await payments.validatePaymentToken(params.token);
return JSON.stringify(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:
import { getContext } from 'webagents';
const context = getContext();
const payments = context.payments;
const paymentToken = payments?.paymentToken;Usage 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:
// Coming soon — track at https://github.com/robutlerai/webagents/issues
// In TypeScript, use `agentFee` (fixed) and `creditsPerToken` (per-token
// override) on PaymentSkillConfig instead of an `amount_calculator`.
// Track parity at ../../internal/python-typescript-parity.md.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.
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 ───────┤