Official TypeScript SDK for JECP — Joint Execution & Commerce Protocol. The open protocol for agent-to-service commerce.
npm install @jecpdev/sdkJECP serves two opposite intents through one protocol:
- Sell to agents — earn revenue from AI agent traffic on your service
- Build with agents — give your agent its own wallet and budget cap
Both share the same SDK.
import { JecpClient, InsufficientBalanceError } from '@jecpdev/sdk';
const jecp = new JecpClient({
agentId: process.env.AGENT_ID!, // jdb_ag_*
apiKey: process.env.AGENT_KEY!, // jdb_ak_*
});
try {
const { output, billing, wallet_balance_after } = await jecp.invoke(
'jobdonebot/content-factory',
'translate',
{ text: 'Hello, world!', target_lang: 'JA' },
{ mandate: { budget_usdc: 1.00 } }, // pre-auth budget cap
);
console.log(output); // { translated: 'こんにちは、世界!' }
console.log('charged:', billing.charged);
console.log('balance after:', wallet_balance_after);
} catch (e) {
if (e instanceof InsufficientBalanceError) {
// Auto-recovery — open the topup URL
console.log('Top up here:', e.nextAction?.api);
} else {
throw e;
}
}import { JecpClient } from '@jecpdev/sdk';
const { agent_id, api_key, free_calls_remaining } = await JecpClient.register({
name: 'MyResearchAgent',
agent_type: 'research',
description: 'Reads PDFs, writes summaries',
});
console.log('Save these forever:');
console.log('AGENT_ID =', agent_id);
console.log('AGENT_KEY =', api_key);
console.log('Free calls:', free_calls_remaining);const { url } = await jecp.topup(20);
// open `url` in browser → pay via Stripe → balance += $20Skip Stripe entirely: pay each invoke from a Base wallet in USDC. No top-ups, no human-in-the-loop, settled in seconds via the x402 protocol. Spec: Locked Design v1.1.1 §3 + §6.
Full 4-command setup: see docs/x402-quickstart.md.
import { JecpClient, walletFromEnv } from '@jecpdev/sdk';
// peer dep: npm install ethers
const jecp = new JecpClient({
agentId: process.env.JECP_AGENT_ID!,
apiKey: process.env.JECP_API_KEY!,
payment: {
mode: 'auto', // 'wallet' | 'x402' | 'auto' (default)
signer: walletFromEnv(), // reads AGENT_BASE_KEY env var
maxPerCallUsdc: 1_000_000n, // safety cap: $1 max per invoke
maxPerHourUsdc: 10_000_000n, // safety cap: $10 rolling 1h budget
maxGasRatio: 0.05, // safety cap: gas <= 5% of invoke amount
},
});
const r = await jecp.invoke('jobdonebot/bg-remover-pro', 'remove', {
image_url: 'https://example.com/cat.png',
});
console.log(r.output); // capability result
console.log(r.payment?.txHash); // 0x... — settlement tx on Base
console.log(r.payment?.amount_usd); // 0.20
// Estimate cost before invoking
const cost = await jecp.estimateCost('jobdonebot/bg-remover-pro');
// → { usd: 0.20, usdc: 200000n, gasEstimateUsd: 0.004 }Three SDK-side caps refuse to sign EIP-3009 authorizations that exceed
budget. Each throws a typed error with a nextAction hint:
| Cap | Error class | nextAction.type |
|---|---|---|
maxPerCallUsdc |
X402AmountCapExceededError |
raise_cap |
maxPerHourUsdc |
X402HourlyCapExceededError |
review_intent |
maxGasRatio |
X402GasRatioExceededError |
check_gas |
Defense-in-depth: an autonomous agent that can be tricked into draining its wallet is a CVE. SDK caps run before signing so a compromised facilitator cannot extract a too-large signature.
walletFromEnv() is convenience; JecpClient accepts any Signer:
import type { Signer } from '@jecpdev/sdk';
const mySigner: Signer = {
async getAddress() { /* ... */ },
async signEIP3009(params) { /* returns { v, r, s } */ },
};Common patterns: viem WalletClient, AWS KMS Ethereum signer, hardware
wallets. The SDK never holds private keys.
Modes:
'auto'(default): try x402 first when capability accepts it; otherwise surface the typed 402 error so the caller can drive Stripe Checkout viaerr.nextAction.'wallet': identical to pre-0.8 behavior. Never attempts x402.'x402': refuse to pay via wallet. ThrowsInsufficientPaymentOptionsErrorif the capability is wallet-only or no signer is configured.
Typed errors (Locked Design §3.5):
X402PaymentInvalidError, X402NotAcceptedError, X402SettlementTimeoutError,
X402FacilitatorUnreachableError, X402SettlementReusedError,
InsufficientPaymentOptionsError. All extend JecpError; each exposes
.subcause and .retryable.
If you're a service provider receiving JECP invocations:
import { JecpProvider } from '@jecpdev/sdk';
const provider = new JecpProvider({
hmacSecret: process.env.JECP_HMAC_SECRET!, // from /v1/providers/register
});
// Works with Bun.serve, Cloudflare Workers, Next.js Route Handlers, Hono...
const handler = provider.createHandler(async (req) => {
// req.capability, req.action, req.input are validated and parsed
switch (req.action) {
case 'translate':
return { translated: await myTranslate(req.input) };
default:
throw new Error(`unknown action: ${req.action}`);
}
});
// Express:
app.post('/jecp', async (req, res) => {
const fetchReq = new Request(`https://${req.hostname}${req.url}`, {
method: 'POST',
headers: req.headers as any,
body: JSON.stringify(req.body),
});
const fetchRes = await handler(fetchReq);
res.status(fetchRes.status).json(await fetchRes.json());
});
// Bun.serve:
Bun.serve({ port: 3000, fetch: handler });Run the full Provider onboarding flow in-process — no CLI shell-out. Same
endpoints @jecpdev/cli calls; same error mapping (NamespaceTakenError,
RotationCapError, ManifestVersionExistsError, etc.).
import { JecpProviderClient, validateManifest } from '@jecpdev/sdk';
import yaml from 'js-yaml';
import { readFileSync } from 'node:fs';
// 1. Register (shown only once — persist immediately)
const creds = await JecpProviderClient.register({
namespace: 'example', display_name: 'Example Co',
owner_email: 'ops@example.com',
endpoint_url: 'https://example.com/jecp', country: 'JP',
});
// Save creds.provider_api_key + creds.hmac_secret to your secret store.
// 2. Verify DNS (polls every 10 s, up to 10 min by default)
const client = new JecpProviderClient({ providerApiKey: creds.provider_api_key });
const dns = await client.verifyDnsPoll({ onAttempt: (n, s) => console.log(n, s) });
if (!dns.verified) throw new Error(`DNS still propagating: ${dns.message}`);
// 3. Validate locally, then publish
const parsed = yaml.load(readFileSync('jecp.yaml', 'utf-8'));
const { valid, errors } = validateManifest(parsed);
if (!valid) { for (const e of errors) console.error(e.instance_path, e.reason); process.exit(1); }
await client.publishManifest(readFileSync('jecp.yaml', 'utf-8'));For Stripe Connect onboarding: await client.connectStripe() — open the
returned onboarding_url in a browser. To rotate the api key:
await client.rotateKey({ revokeOld: true }) — the new api_key is in
the response and shown only once.
Every JECP error includes a machine-readable next_action so agents can recover automatically:
try {
await jecp.invoke('any/cap', 'action', {});
} catch (e) {
if (e instanceof JecpError) {
switch (e.nextAction?.type) {
case 'topup': // open Stripe checkout
await jecp.topup(20);
break;
case 'register': // agent not authenticated
await JecpClient.register({ name: 'NewAgent' });
break;
case 'discover': // capability typo
const cat = await jecp.catalog();
// pick a real one
break;
case 'retry_after': // rate limited
await sleep(60_000);
break;
// ...
}
}
}| Class | Code | When |
|---|---|---|
InsufficientBalanceError |
INSUFFICIENT_BALANCE |
Wallet too low for action |
InsufficientBudgetError |
INSUFFICIENT_BUDGET |
Mandate budget < action price |
MandateExpiredError |
MANDATE_EXPIRED |
expires_at in the past |
AuthError |
AUTH_REQUIRED / INVALID_AGENT |
Missing or wrong credentials |
RateLimitError |
RATE_LIMITED |
60 RPM/agent default cap |
CapabilityNotFoundError |
CAPABILITY_NOT_FOUND |
Unknown namespace/capability |
ActionNotFoundError |
ACTION_NOT_FOUND |
Action not in manifest |
InsufficientTrustError |
INSUFFICIENT_TRUST |
Action requires higher Trust Tier |
ProviderError |
PROVIDER_ERROR / PROVIDER_UNREACHABLE |
Provider endpoint failure |
JecpError |
(anything else) | Generic |
All errors carry .code, .status, .message, .nextAction, .raw.
For machine-readable explanation of JECP (used by AI agents to understand the protocol):
const guide = await JecpClient.agentGuide();
// or fetch directly: https://jecp.dev/.well-known/agent-guide.jsonconst jecp = new JecpClient({
agentId, apiKey,
// All optional, sensible defaults:
timeoutMs: 30_000, // per-call default
retryConfig: { maxRetries: 3 }, // exp backoff + jitter on 5xx/408/429/network
logger: console, // observe retries/timeouts/errors
});
// AbortSignal + per-call timeout supported on every method
const ctl = new AbortController();
const r = await jecp.invoke('a/b', 'c', input, {
signal: ctl.signal,
timeoutMs: 60_000,
});
console.log('attempts taken:', r.attempts);
console.log('idempotency key:', r.request_id);Auto-retry preserves the same request_id across attempts so the Hub's idempotency
cache prevents double-charging.
For Cloudflare Workers, Deno, Vite/webpack browser builds, or any runtime without
node:crypto:
import { JecpClient, JecpProvider } from '@jecpdev/sdk/browser';The browser entry uses Web Crypto API exclusively. Same public API, different
HMAC backend. Build output is split (dist/index.js for Node,
dist/index-browser.js for edge).
When the Hub posts asynchronous events (invocation.completed,
wallet.low_balance, provider.kyc_status_changed):
import { verifyWebhook } from '@jecpdev/sdk';
app.post('/jecp/webhook', async (req) => {
try {
const event = await verifyWebhook({
body: req.rawBody, // raw bytes
signature: req.headers['x-jecp-webhook-signature'],
timestamp: req.headers['x-jecp-webhook-timestamp'],
secret: process.env.JECP_WEBHOOK_SECRET!,
});
// event.type, event.data
} catch (e) {
return new Response('invalid signature', { status: 401 });
}
});Replay window defaults to ±5 min. Configurable via replayWindowSec.
Runnable examples in examples/:
01-register-and-invoke.ts— register + first call02-error-recovery.ts—next_actiondiscriminated-union recovery03-mandate-budget-cap.ts— pre-authorized spend cap04-provider-server.ts— Provider endpoint with HMAC05-x402-invoke.ts— pay each invoke in USDC on Base (v0.8.2)
The default npm test is hermetic — only unit tests with mocked fetch.
A separate, opt-in suite exercises the SDK against a live JECP Hub
to catch wire/schema drift that mocks can't see:
# Against the default staging Hub (https://setsuna-jobdonebot.fly.dev)
npm run test:integration
# Against a local Hub
JECP_TEST_BASE_URL=http://localhost:8080 npm run test:integration
# Against jecp.dev (production — coordinate first)
JECP_TEST_BASE_URL=https://jecp.dev npm run test:integrationIntegration tests live in test/integration/ and
are excluded from the default unit suite via vitest.config.ts. See
test/integration/README.md for the
host-topology assumption (catalog/invoke live on the Hub, but
/api/agents/register lives on the JobDoneBot Next.js app).
- Spec: https://github.com/jecpdev/jecp-spec
- Live catalog: https://jecp.dev/v1/capabilities
- Health: https://jecp.dev/health
- Discussions: https://github.com/jecpdev/jecp-spec/discussions
- Changelog: CHANGELOG.md
- Email: hello@jecp.dev
Apache License 2.0 · Maintained by Tufe Company Inc.