Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/add-x402station-action-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Added a new `x402station` action provider — a pre-flight oracle for the x402 agentic-commerce network. Six tools (preflight, forensics, catalog_decoys, watch_subscribe, watch_status, watch_unsubscribe) wrapping the public oracle at https://x402station.io. Four are paid via x402 ($0.001–$0.01 USDC, auto-signed via the agent's `EvmWalletProvider`); two are free + secret-gated for managing an existing webhook subscription. Networks: Base mainnet and Base Sepolia.
1 change: 1 addition & 0 deletions typescript/agentkit/src/action-providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export * from "./flaunch";
export * from "./onramp";
export * from "./vaultsfyi";
export * from "./x402";
export * from "./x402station";
export * from "./yelay";
export * from "./zerion";
export * from "./zerodev";
Expand Down
110 changes: 110 additions & 0 deletions typescript/agentkit/src/action-providers/x402station/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# X402station Action Provider

The `x402station` action provider gives any AgentKit agent a pre-flight oracle for x402 endpoints. Four paid tools (`preflight`, `forensics`, `catalog_decoys`, `watch_subscribe`) auto-sign their own $0.001–$0.01 USDC payments through the agent's configured `EvmWalletProvider`; two free secret-gated tools (`watch_status`, `watch_unsubscribe`) manage an existing webhook subscription.

This is a wrapper around the public oracle at [x402station.io](https://x402station.io) — same API + identical signal vocabulary used by the official `x402station-mcp` package on npm.

## Why pre-flight?

The agentic.market catalog has 25,000+ x402 endpoints. A non-trivial fraction are honeypots:

- **Decoys** priced ≥ $1,000 USDC per call. An agent that pays one drains its wallet.
- **Zombies** that 402-handshake fine but always 4xx after settlement (the call-side payment goes through, the agent gets nothing).
- **Dead** endpoints that return network errors or 5xx every probe.
- **Price-jacked** endpoints whose listed price drifted 10× past the provider's group median.

x402station independently probes every endpoint every ~10 minutes (not facilitator-reported) so it catches what facilitator-only monitors miss. Calling `preflight` before each paid x402 request costs $0.001 — typically 20× cheaper than the request the agent would otherwise lose to a decoy.

## Networks

- Base mainnet (`base-mainnet` / `eip155:8453`) — production
- Base Sepolia (`base-sepolia` / `eip155:84532`) — testing

The oracle accepts USDC payments on both networks via Coinbase's CDP facilitator; the action provider's `supportsNetwork` returns `false` for any other network.

## Actions

| Action | Cost | Description |
|---|---|---|
| `preflight` | $0.001 | `{ok, warnings[], metadata}` for any URL — fast safety check |
| `forensics` | $0.001 | 7-day uptime + latency p50/p90/p99 + decoy probability + concentration stats |
| `catalog_decoys` | $0.005 | Every URL flagged dangerous, in one cacheable blob |
| `watch_subscribe` | $0.01 | 30-day webhook subscription + 100 prepaid HMAC-signed alerts |
| `watch_status` | free* | Read-back: active/expired, alerts remaining, recent deliveries |
| `watch_unsubscribe` | free* | Soft-delete a watch |

\* Free actions are secret-gated by the 64-char hex secret returned from `watch_subscribe`. Constant-time compare on the server; mismatched secret returns 404 (not 401) so an attacker scraping IDs can't distinguish "exists but wrong secret" from "doesn't exist".

## Signal vocabulary

Strings returned in `warnings[]` from `preflight` / `forensics`. **Bold** signals flip `ok` to `false` and an agent should refuse the target call:

- **`dead`** — ≥3 unhealthy probes in the last 30 min
- **`zombie`** — ≥3 probes in the last hour, zero healthy
- **`decoy_price_extreme`** — listed price ≥ $1,000 USDC
- **`dead_7d`** — ≥20 probes over 7 days, zero healthy (forensics-only)
- **`mostly_dead`** — ≥20 probes over 7 days, uptime < 50% (forensics-only)
- `unknown_endpoint` — URL not in the catalog (informational; still billed)
- `no_history` — in catalog but no probes in the last hour
- `suspicious_high_price` — price $10–$1,000 USDC
- `slow` — avg latency ≥ 2,000 ms in the last hour
- `new_provider` — service first seen < 24h ago
- `slow_p99` — latency p99 ≥ 5,000 ms (forensics-only)
- `price_outlier_high` — current price > 10× provider-group median
- `high_concentration` — endpoint's provider owns ≥ 5% of the catalog

The watch endpoint accepts a subset of these in its `signals` array — the worker fires when subscribed signals appear or clear vs the last computed state.

## Example

```typescript
import {
AgentKit,
CdpEvmServerWalletProvider,
x402stationActionProvider,
} from "@coinbase/agentkit";

const walletProvider = await CdpEvmServerWalletProvider.configureWithWallet({
apiKeyId: process.env.CDP_API_KEY_ID!,
apiKeySecret: process.env.CDP_API_KEY_SECRET!,
walletSecret: process.env.CDP_WALLET_SECRET!,
networkId: "base-mainnet",
});

const agentKit = await AgentKit.from({
walletProvider,
actionProviders: [x402stationActionProvider()],
});

// The LLM can now call preflight, forensics, etc. via getActions().
// Pre-flight a target before the agent commits a paid call to it:
const actions = agentKit.getActions();
const preflight = actions.find((a) => a.name.endsWith("_preflight"))!;
const result = await preflight.invoke({
url: "https://api.venice.ai/api/v1/chat/completions",
});
console.log(JSON.parse(result));
// { result: { ok: false, warnings: ["dead", "zombie"], metadata: {...} },
// paymentReceipt: { transaction: "0x…" } }
```

## Configuration

```typescript
x402stationActionProvider({
// Defaults to https://x402station.io. Only the canonical host or a
// localhost dev URL is accepted — refuses to start otherwise so a
// misconfigured agent can't sign x402 payments against an unknown host.
baseUrl: "https://x402station.io",
});
```

## Links

- Service: <https://x402station.io>
- Manifest: <https://x402station.io/.well-known/x402>
- OpenAPI: <https://x402station.io/api/openapi.json>
- Agent skills (v0.2.0): <https://x402station.io/.well-known/agent-skills>
- Skill description: <https://x402station.io/skill.md>
- Source: <https://github.com/sF1nX/x402station>
- npm (MCP adapter for non-AgentKit agents): <https://www.npmjs.com/package/x402station-mcp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
X402stationActionProvider,
x402stationActionProvider,
} from "./x402stationActionProvider";
export type { X402stationConfig } from "./schemas";
164 changes: 164 additions & 0 deletions typescript/agentkit/src/action-providers/x402station/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { z } from "zod";

/**
* Configuration options for X402stationActionProvider.
*/
export interface X402stationConfig {
/**
* Override the default oracle base URL.
*
* Allowed values: `https://x402station.io` (canonical, default) or any
* `http(s)://localhost*` for development. Any other host is rejected at
* construction time so a misconfigured agent can't sign x402 payments
* against an attacker-controlled URL.
*/
baseUrl?: string;
}

/**
* Signal vocabulary returned by the oracle. Whitelisted at the schema
* level so a typo in the agent's `signals` array doesn't silently never
* fire (the route would 400, but catching it earlier saves a wallet
* round-trip).
*
* Critical signals (those that flip preflight `ok` to `false`):
* `dead`, `zombie`, `decoy_price_extreme`, `dead_7d`, `mostly_dead`
*/
export const SignalEnum = z.enum([
"unknown_endpoint",
"no_history",
"dead",
"zombie",
"decoy_price_extreme",
"suspicious_high_price",
"slow",
"new_provider",
"dead_7d",
"mostly_dead",
"slow_p99",
"price_outlier_high",
"high_concentration",
]);

/**
* Input schema for the `preflight` and `forensics` actions.
*/
export const PreflightSchema = z.object({
url: z
.string()
.url()
.describe(
"Full URL of the x402 endpoint the agent is about to pay (must be http(s)://, max 2048 chars).",
),
});

export const ForensicsSchema = PreflightSchema;

/**
* Empty input — no parameters needed.
*/
export const CatalogDecoysSchema = z.object({}).describe("No parameters required");

/**
* Input for the `whats_new` action — catalog diff polling. `since` is an ISO
* 8601 timestamp (default = now() - 24h, cap 30 days back). `limit` caps each
* of added_endpoints[] and removed_endpoints[] (1..500, default 200).
*/
export const WhatsNewSchema = z.object({
since: z
.string()
.datetime()
.optional()
.describe(
"ISO 8601 timestamp. Default = now() - 24h. Cannot be older than 30 days or in the future.",
),
limit: z
.number()
.int()
.min(1)
.max(500)
.optional()
.describe(
"Per-list cap (1..500, default 200). Applied independently to added_endpoints and removed_endpoints.",
),
});

/**
* Input for the `alternatives` action — given a flagged URL OR a taskClass
* hint, returns up to `limit` (default 5, max 10) healthy sibling endpoints
* in the same provider / domain / category / price-band. Filtered to those
* passing the same 1h + 7d health checks preflight uses; ranked by
* uptime_7d_pct DESC then avg_latency_1h_ms ASC. At least one of `url` or
* `taskClass` is required.
*/
export const AlternativesSchema = z
.object({
url: z
.string()
.url()
.optional()
.describe(
"URL flagged by preflight (or otherwise rejected). Looked up in the catalog to extract provider / domain / category / price band as match keys.",
),
taskClass: z
.string()
.max(80)
.optional()
.describe(
"Service category hint (e.g. 'llm-completions', 'Inference'). Used as a fallback match key when `url` is unknown to the catalog, OR alone for category-only discovery.",
),
limit: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe("Max alternatives to return (1..10, default 5)."),
})
.refine((v) => v.url !== undefined || v.taskClass !== undefined, {
message: "alternatives requires at least one of `url` or `taskClass`",
});

/**
* Input for `watch_subscribe`. Pays $0.01 USDC, returns a watchId + a 64-char
* hex secret. The secret is the HMAC seed for verifying delivery payloads
* and is only returned once — store it.
*/
export const WatchSubscribeSchema = z.object({
url: z
.string()
.url()
.describe("The x402 endpoint URL to watch."),
webhookUrl: z
.string()
.url()
.refine((u) => u.startsWith("https://"), {
message:
"webhookUrl must use HTTPS — HMAC-signed alert payloads must not travel in clear text",
})
.describe(
"Where x402station will POST alert payloads. Must be HTTPS (HMAC-signed payloads must travel encrypted) and reachable from the public internet.",
),
signals: z
.array(SignalEnum)
.min(1)
.max(20)
.optional()
.describe(
"Signal names to alert on. Defaults to ['dead', 'zombie', 'decoy_price_extreme'].",
),
});

/**
* Input for `watch_status` and `watch_unsubscribe`. Both are free + secret-gated.
*/
export const WatchStatusSchema = z.object({
watchId: z.string().uuid().describe("The watchId UUID returned by watch_subscribe."),
secret: z
.string()
.length(64)
.regex(/^[0-9a-f]{64}$/i, "secret must be 64 hex chars")
.describe("The 64-char hex secret returned by watch_subscribe (store it; not retrievable later)."),
});

export const WatchUnsubscribeSchema = WatchStatusSchema;
Loading
Loading