Extend
Write an adapter
Add a protocol or a payment rail without touching the core — and the rules every adapter follows.
Two extension points exist, and neither should require changing src/core. If yours does, raise it as a contract change before writing code.
A protocol adapter
Implement ProtocolAdapter, or HttpProtocolAdapter for a protocol served over HTTP. You get one thing to work with: a ProtocolAdapterContext — the pipeline, the resource registry, an event sink, a logger, a clock, an id generator and the public base URL.
import type { HttpProtocolAdapter, ProtocolAdapterContext } from '@devlab.group/agent-commerce';
import { toCommerceError, toErrorEnvelope, toPaymentRequiredEnvelope } from '@devlab.group/agent-commerce';
export function createExampleAdapter(): HttpProtocolAdapter {
let ctx: ProtocolAdapterContext | undefined;
return {
name: 'example',
mountPath: '/example',
descriptor: {
name: 'example',
kind: 'protocol',
implementationVersion: '1.0.0',
supportedSpec: 'example-spec@2026-01-01', // pin it
capabilities: ['discovery', 'invoke'],
unsupported: ['subscriptions', 'batch'], // be explicit
status: 'experimental',
},
async start(context) {
ctx = context;
for (const resource of context.resources.listExposedVia('example')) {
// map resource.inputSchema into your protocol's schema language
}
},
async handleHttp(req, res) {
if (!ctx) throw new Error('adapter not started');
try {
const outcome = await ctx.pipeline.execute({
requestId: ctx.ids.next('req'),
resourceId: /* from the wire */ '',
input: /* from the wire, payment field removed */ {},
protocol: 'example',
receivedAt: ctx.clock.nowIso(),
});
if (outcome.kind === 'payment-required') {
return respond(res, 402, toPaymentRequiredEnvelope(outcome));
}
respond(res, 200, outcome.body);
} catch (error) {
const commerceError = toCommerceError(error);
respond(res, commerceError.httpStatus, toErrorEnvelope(commerceError));
}
},
async health() {
return { status: ctx ? 'pass' : 'fail', checkedAt: new Date().toISOString() };
},
async stop() {
ctx = undefined;
},
};
}Rules
- Never call a merchant backend. Everything goes through
pipeline.execute; bypassing it bypasses payment enforcement. - Never implement payment logic. Surface the payment-required outcome; do not decide what is owed.
- Map errors deterministically with
toErrorEnvelope(toCommerceError(e)). No stack traces on the wire. - Fail in isolation, and be honest in the descriptor.
A payment provider
Implement PaymentProvider: createRequirement, verify, settle and health.
export function createExampleProvider(options: ExampleOptions): PaymentProvider {
return {
name: 'example-rail',
descriptor: { /* honest */ },
async createRequirement(context) {
return {
id: /* … */,
requestId: context.requestId,
resourceId: context.resource.id,
provider: 'example-rail',
amount: context.amount, // decimal display units, unchanged
currency: context.currency,
destination: options.payTo, // merchant-controlled
challenge: { provider: 'example-rail', version: '1', accepts: [/* native, opaque */] },
};
},
async verify(context) {
// No side effects that move money. Ever.
// Return { status: 'rejected', rejectionReason } for a bad proof.
// MUST return a replayKey derived ONLY from the authorisation.
},
async settle(context) {
// Runs only after verify succeeded AND the replay key was reserved.
},
async health() { /* fast, never throws */ },
};
}verifymust not move funds;settleis the only place that does.- Distinguish rejected (bad payment, not retryable) from unavailable (rail down, retryable).
- The gateway must never need a merchant or buyer private key to use your rail.
Before review
Protocol adapter checklistSchema mapping, request normalisation with
_payment stripped, shared envelopes verbatim, conformance fixtures, tests through a real client.Payment provider checklistNegative tests for every failure, a real settlement proof, no key material, an honest descriptor.
See CONTRIBUTING.md for the development loop.