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
59 changes: 59 additions & 0 deletions typescript/examples/langchain-x402-guard-chatbot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# AgentKit x402 buy loop with an opt-in pre-payment guard

A self-contained example: an AgentKit agent that pays x402 APIs, but **checks the
seller wallet before it pays**. It adds nothing to agentkit core — it composes
the existing `x402ActionProvider` actions with one small, example-local action
provider.

## The buy loop: `inspect → check → decide → pay`

`x402ActionProvider` already separates inspecting a 402 from paying it:

- `make_http_request` — returns the 402 payment requirements (including the
seller `payTo` and `network`) **without paying**.
- `retry_http_request_with_x402` — **pays**.

This example drops a check in between:

```
make_http_request → check_x402_seller → retry_http_request_with_x402
```

`check_x402_seller` (in [`meronaGuard.ts`](./meronaGuard.ts)) calls
[merona](https://merona.io)'s free, keyless trust API and returns a recorded-fact
**PASS / FAIL / UNCERTAIN** verdict. The agent is instructed not to pay on a
recorded FAIL, and to ask the user on UNCERTAIN.

merona is an independent x402 settlement index; its verdicts are recomputable
from public on-chain data and it holds no seller relationships. The check is
**fail-soft** — if merona is unreachable it returns UNCERTAIN rather than
throwing, so it never blocks the agent's loop. It uses the published
[`merona-guard`](https://www.npmjs.com/package/merona-guard) package (zero
runtime dependencies) and adds no dependency to agentkit itself.

## Run it

```bash
npm install
export OPENAI_API_KEY=... # the model
export CDP_API_KEY_ID=... # CDP wallet
export CDP_API_KEY_SECRET=...
export CDP_WALLET_SECRET=...
export NETWORK_ID=base-sepolia # or base
npm start
```

Then ask it to fetch a paid x402 URL. On a 402 it will read the requirements,
run `check_x402_seller` on the seller, and only pay if the seller isn't a
recorded FAIL.

> Coverage note: merona's delivery-probe evidence is strongest on Base. On
> networks it doesn't index yet the verdict is UNCERTAIN — which, being
> fail-soft, means "proceed, but I have no evidence either way."

## What this demonstrates

A pre-payment counterparty check is a natural, opt-in extension of the x402 buy
loop. The guard here is provider-agnostic in spirit — `meronaGuard.ts` is ~40
lines and could point at any trust source; it defaults to merona because it's
free and needs no key.
170 changes: 170 additions & 0 deletions typescript/examples/langchain-x402-guard-chatbot/chatbot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {
AgentKit,
CdpEvmWalletProvider,
walletActionProvider,
cdpApiActionProvider,
cdpEvmWalletActionProvider,
erc20ActionProvider,
x402ActionProvider,
} from "@coinbase/agentkit";
import { getLangChainTools } from "@coinbase/agentkit-langchain";
import { HumanMessage } from "@langchain/core/messages";
import { MemorySaver } from "@langchain/langgraph";
import { createAgent } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import * as dotenv from "dotenv";
import * as readline from "readline";
import { meronaGuardActionProvider } from "./meronaGuard";

dotenv.config();

/**
* Validates that required environment variables are set.
*
* @throws {Error} If required environment variables are missing.
* @returns {void}
*/
function validateEnvironment(): void {
const missingVars: string[] = [];
const requiredVars = ["OPENAI_API_KEY", "CDP_API_KEY_ID", "CDP_API_KEY_SECRET", "CDP_WALLET_SECRET"];
requiredVars.forEach(varName => {
if (!process.env[varName]) missingVars.push(varName);
});
if (missingVars.length > 0) {
console.error("Error: Required environment variables are not set");
missingVars.forEach(varName => console.error(`${varName}=your_${varName.toLowerCase()}_here`));
process.exit(1);
}
if (!process.env.NETWORK_ID) {
console.warn("Warning: NETWORK_ID not set, defaulting to base-sepolia testnet");
}
}

validateEnvironment();

/**
* Initialize the agent with AgentKit, the x402 action provider, and the opt-in
* merona pre-payment guard.
*
* @returns {Promise<{ agent: unknown; config: unknown }>} The agent and its config.
*/
async function initializeAgent() {
const llm = new ChatOpenAI({ model: "gpt-4o-mini" });

const networkId = process.env.NETWORK_ID || "base-sepolia";

const walletProvider = await CdpEvmWalletProvider.configureWithWallet({
apiKeyId: process.env.CDP_API_KEY_ID,
apiKeySecret: process.env.CDP_API_KEY_SECRET,
walletSecret: process.env.CDP_WALLET_SECRET,
idempotencyKey: process.env.IDEMPOTENCY_KEY,
address: process.env.ADDRESS as `0x${string}` | undefined,
networkId,
rpcUrl: process.env.RPC_URL,
});

// x402 configuration
const x402Config = {
registeredServices: networkId === "base-sepolia" ? ["https://www.x402.org/protected"] : [],
allowDynamicServiceRegistration: false,
maxPaymentUsdc: 1.0,
registeredFacilitators: {},
};

const agentkit = await AgentKit.from({
walletProvider,
actionProviders: [
walletActionProvider(),
cdpApiActionProvider(),
cdpEvmWalletActionProvider(),
erc20ActionProvider(),
x402ActionProvider(x402Config), // make_http_request, retry_http_request_with_x402, ...
meronaGuardActionProvider(), // check_x402_seller (this example)
],
});

const tools = await getLangChainTools(agentkit);
const memory = new MemorySaver();
const agentConfig = { configurable: { thread_id: "x402 buy-loop with merona guard" } };

const agent = createAgent({
model: llm,
tools,
checkpointer: memory,
systemPrompt: `
You are an agent that can pay for x402 APIs onchain using Coinbase AgentKit.

The buy loop is: inspect -> check -> decide -> pay.
When a request returns HTTP 402:
1. Call make_http_request to read the payment requirements.
2. Take the seller 'payTo' and 'network' from those requirements and call
check_x402_seller with them.
3. Only call retry_http_request_with_x402 if the check is NOT a FAIL.
If the check is UNCERTAIN, tell the user what it says and ask before paying.
If it is a FAIL, do not pay; report the recorded reason.

The check is free and fail-soft: if it is unavailable it returns UNCERTAIN,
which is a signal, not an error. Before your first action, get the wallet
details to see what network you're on. Be concise.
`,
});

return { agent, config: agentConfig };
}

/**
* Run the agent interactively based on user input.
*
* @param {unknown} agent - The agent executor.
* @param {unknown} config - Agent configuration.
* @returns {Promise<void>}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function runChatMode(agent: any, config: any) {
console.log("Starting chat mode... Type 'exit' to end.");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const question = (prompt: string): Promise<string> =>
new Promise(resolve => rl.question(prompt, resolve));

try {
// eslint-disable-next-line no-constant-condition
while (true) {
const userInput = await question("\nPrompt: ");
if (userInput.toLowerCase() === "exit") break;

const stream = await agent.stream({ messages: [new HumanMessage(userInput)] }, config);
for await (const chunk of stream) {
if ("model_request" in chunk) {
const response = chunk.model_request.messages[0].content;
if (response !== "") console.log("\n" + response);
}
if ("tools" in chunk) {
for (const tool of chunk.tools.messages) {
console.log("Tool " + tool.name + ": " + tool.content);
}
}
}
console.log("-------------------");
}
} catch (error) {
if (error instanceof Error) console.error("Error:", error.message);
process.exit(1);
} finally {
rl.close();
}
}

/**
* Start the chatbot agent.
*
* @returns {Promise<void>}
*/
async function main() {
const { agent, config } = await initializeAgent();
await runChatMode(agent, config);
}

main().catch(error => {
console.error("Fatal error:", error);
process.exit(1);
});
57 changes: 57 additions & 0 deletions typescript/examples/langchain-x402-guard-chatbot/meronaGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { customActionProvider } from "@coinbase/agentkit";
import { Guard } from "merona-guard";
import { z } from "zod";

/**
* Map an x402 network id to a chain merona indexes. x402 uses CAIP-2
* (`eip155:8453`, `solana:...`); merona covers base / polygon / solana. Anything
* else is passed through — merona then returns an UNCERTAIN the model can act on.
*/
function meronaChain(network: string): string {
const n = network.toLowerCase();
if (n === "eip155:8453" || n === "base") return "base";
if (n === "eip155:137" || n === "polygon") return "polygon";
if (n.startsWith("solana")) return "solana";
return n;
}

// Free, no key. Verdicts are cached for their TTL and fail soft: if merona is
// unreachable the result is an UNCERTAIN, never a thrown error into the loop.
const guard = new Guard();

/**
* A self-contained, opt-in action provider that checks an x402 seller wallet
* with merona BEFORE the agent pays it. It slots between x402ActionProvider's
* two existing actions:
*
* make_http_request -> check_x402_seller -> retry_http_request_with_x402
*
* `make_http_request` returns the 402 payment requirements (including the seller
* `payTo` and `network`); this action checks that seller; the agent only calls
* `retry_http_request_with_x402` if the verdict is not a recorded FAIL.
*
* merona (https://merona.io) is a free, keyless, independent x402 settlement
* index. Verdicts are PASS / FAIL / UNCERTAIN and recomputable from public
* on-chain data. The returned reason is recorded-fact only — a model reasoning
* over it reads "a paid probe recorded a settled payment with no content
* returned", never a slur about the seller.
*/
export const meronaGuardActionProvider = () =>
customActionProvider({
name: "check_x402_seller",
description:
"Check an x402 seller wallet with merona BEFORE paying it. Returns a recorded-fact " +
"PASS / FAIL / UNCERTAIN verdict. Call this after make_http_request returns the 402 " +
"payment requirements, using their payTo and network, and BEFORE calling " +
"retry_http_request_with_x402. Do not pay on FAIL; on UNCERTAIN, ask the user first.",
schema: z.object({
payTo: z.string().describe("The seller wallet address from the 402 payment requirements"),
network: z
.string()
.describe("The network from the 402 requirements, e.g. 'base' or a CAIP-2 id like 'eip155:8453'"),
}),
invoke: async (args: { payTo: string; network: string }) => {
const r = await guard.check(args.payTo, meronaChain(args.network));
return r.explain();
},
});
28 changes: 28 additions & 0 deletions typescript/examples/langchain-x402-guard-chatbot/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@coinbase/langchain-x402-guard-chatbot-example",
"description": "AgentKit x402 buy-loop example with an opt-in merona pre-payment seller check",
"version": "1.0.0",
"private": true,
"author": "merona <hello@merona.io>",
"license": "Apache-2.0",
"scripts": {
"start": "NODE_OPTIONS='--no-warnings' tsx ./chatbot.ts",
"dev": "nodemon ./chatbot.ts"
},
"dependencies": {
"@coinbase/agentkit": "workspace:*",
"@coinbase/agentkit-langchain": "workspace:*",
"@langchain/core": "^1.1.0",
"@langchain/langgraph": "^1.2.0",
"@langchain/openai": "^1.2.0",
"dotenv": "^16.4.5",
"langchain": "^1.1.0",
"merona-guard": "^0.1.0",
"zod": "^4.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0",
"tsx": "^4.7.1",
"typescript": "^5.6.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"preserveSymlinks": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"]
}
Loading