TypeScript SDK + CLI for the Consensus Network.
Route outbound HTTP through decentralized proxy nodes and open paid WebSocket sessions with x402 micropayments.
Part of the Consensus Protocol · see also consensus-node
Architecture note (in progress): the client is being updated for the control-plane / data-plane split — instead of the orchestrator relaying every request, the client asks the server to select a node, then connects directly to that node with a short-lived signed ticket. The public APIs documented below are intended to stay stable across the migration.
npm install @canister-software/consensus-cliCreate a .env file in the root of your project with your CDP credentials:
CDP_API_KEY_ID=your_cdp_key_id
CDP_API_KEY_SECRET=your_cdp_key_secret
CDP_WALLET_SECRET=your_cdp_wallet_secretGet your CDP credentials at portal.cdp.coinbase.com.
You can also override the default server with:
CONSENSUS_SERVER_URL=https://your-custom-node.example.comProxyClient(fetchWithPayment, options) returns a framework-agnostic proxy controller with Express-compatible middleware behavior. It routes outbound HTTP requests through Consensus proxy nodes and supports automatic spend-limit stand-down.
| Option | Type | Default | Description |
|---|---|---|---|
mode |
"only" | "except" |
"except" |
only proxies only the listed routes (allowlist). except proxies everything but the listed routes (denylist). Required whenever routes is non-empty. |
routes |
string[] |
[] |
Path rules selected by mode. Query params are ignored; matching is on path only. |
matchSubroutes |
boolean |
false |
When true, a route match also applies to all sub-paths beneath it. |
strategy |
"auto" | "manual" |
"auto" |
auto transparently intercepts fetch() calls within middleware. manual exposes req.consensus.fetch() for explicit control. |
profiles |
Record<string, ProxyProfile> |
{} |
Local named policies compiled into anonymous versioned execution plans for the server and node. |
profile |
string |
— | Default local profile name, overridable per request. |
cache_ttl |
number |
— | TTL in seconds for node-level response caching. |
verbose |
boolean |
false |
Enables verbose response metadata from the proxy node. |
node_region |
string |
— | Prefer proxy nodes in a specific geographic region. |
node_domain |
string |
— | Route through a specific node domain. |
node_exclude |
string |
— | Exclude a specific node domain from selection. |
limit_usd |
number |
— | Max proxy spend in USD (up to 6 decimals). When reached, proxying stands down to direct fetch. |
on_limit_reached |
(budget) => void |
— | Callback fired once when stand-down is activated. |
Proxy spend tracking uses the fixed server price of $0.0001 per paid /proxy request (cached hits are not charged).
mode decides how routes is read. The two are exact inverses:
ProxyClient(fetchWithPayment, { mode: 'only', routes: ['/api'] }); // proxy only /api
ProxyClient(fetchWithPayment, { mode: 'except', routes: ['/health'] }); // proxy everything but /health
ProxyClient(fetchWithPayment, {}); // proxy everythingmode is required whenever routes is non-empty. { routes: ['/api'] } reads equally well as "proxy /api" and "don't proxy /api", and picking one silently would mean billing you for the opposite of what you meant, so it throws instead. Three other misconfigurations are rejected at construction rather than silently changing what you pay for:
| Config | Result |
|---|---|
{ routes: ['/api'] } |
throws — say which mode you meant |
{ mode: 'exclusve', routes: ['/api'] } |
throws — unrecognized mode, no silent fallback |
{ mode: 'only', routes: [] } |
throws — an empty allowlist would disable proxying entirely |
{ mode: 'except', routes: [] } |
fine — proxies everything (the default) |
Renamed in 0.2.0.
modewas'inclusive' | 'exclusive'. Those names described the proxy's breadth while sitting next toroutes, so they read as the inverse of what they did — under'inclusive', listing a route excluded it.'exclusive'is now'only'and'inclusive'is now'except'. The old names still work as deprecated aliases with identical behaviour.
Profiles remain anonymous: the local name is never registered or transmitted, while the SDK sends a normalized profile-v1 execution plan with each request. The main server and compatible nodes independently validate the origin, base path, allowed paths, and method; the profile hash is bound into cache deduplication and direct-routing tickets.
const proxy = ProxyClient(fetchWithPayment, {
profiles: {
catalog: {
base_url: 'https://api.example.com/v1',
allowed_methods: ['GET'],
allowed_paths: ['/products', '/search'],
cache_ttl: 120,
node_region: 'us-east',
},
},
profile: 'catalog',
});
await proxy.fetch('/products/42');
await proxy.fetch('/search?q=node', {}, { profile: 'catalog', cache_ttl: 30 });Profiles are a forward-proxy request feature and work through both node execution and the main-server fallback; they are not advertised as a node capability.
In auto mode, ProxyClient intercepts the global fetch() within the request context so your route handlers require no changes.
import express from 'express';
import { ProxyClient } from '@canister-software/consensus-cli';
const app = express();
// Proxy only /price — all other routes use direct fetch
app.use(
ProxyClient(fetchWithPayment, {
mode: 'only',
routes: ['/price'],
matchSubroutes: false,
strategy: 'auto',
cache_ttl: 60,
verbose: true,
})
);
// No changes needed — fetch() is automatically proxied for /price
app.get('/price', async (_req, res) => {
const response = await fetch('https://api.example.com/price');
res.json(await response.json());
});In manual mode, the proxy is not applied automatically. Use req.consensus.fetch() to explicitly proxy individual requests, or req.consensus.request() for a lower-level structured payload:
app.use(ProxyClient(fetchWithPayment, { strategy: 'manual' }));
app.get('/data', async (req, res) => {
// Proxied fetch — returns a standard Response
const response = await req.consensus.fetch('https://api.example.com/data');
res.json(await response.json());
// Or use the structured request helper — returns a ProxyResponseShape
const result = await req.consensus.request({
target_url: 'https://api.example.com/data',
method: 'GET',
});
res.json(result.data);
});Both req.consensus.fetch() and req.consensus.request() accept per-request options as a second argument to override node routing at the call level:
const response = await req.consensus.fetch(
'https://api.example.com/data',
{ method: 'GET' },
{ node_region: 'us-east', cache_ttl: 30 }
);batch() runs many proxy requests as one group. Pass mode to choose how they are dispatched:
const results = await proxy.batch(
[
'https://api.example.com/users/1',
'https://api.example.com/users/2',
{ target_url: 'https://api.example.com/events', method: 'POST', body: { kind: 'sync' } },
],
{ mode: 'parallel', concurrency: 4 }
);| Option | Default | Meaning |
|---|---|---|
mode |
'parallel' |
'parallel' dispatches concurrently; 'sequential' runs strictly one at a time, in input order. |
concurrency |
8 |
Max requests in flight in 'parallel' mode. Ignored by 'sequential', which is always one at a time. |
signal |
— | An AbortSignal that stops dispatching further items. |
Any per-request option (profile, cache_ttl, verbose, node_region, node_domain, node_exclude, direct) can also be set at the batch level, and overridden on an individual item via its options key:
const results = await proxy.batch(
[
'/products/1',
{ target_url: '/products/2', options: { cache_ttl: 5 } },
],
{ mode: 'sequential', profile: 'catalog', cache_ttl: 120 }
);Every item settles. batch() never rejects because one request failed — results come back in input order as a discriminated union, so a partial failure is a normal, inspectable outcome:
for (const result of results) {
if (result.ok) {
console.log(result.index, result.value.status, result.value.data);
} else {
console.error(result.index, result.error.status, result.error.message);
}
}
const succeeded = results.filter((r) => r.ok).map((r) => r.value);It does reject before dispatching anything if the input itself is malformed (a missing target_url, a bad mode, a non-positive concurrency) — that's a programming error, and finding it halfway through a paid batch would be an expensive way to learn about a typo.
Each item is an independent paid /proxy request, so caching/dedupe, profiles, direct node routing, and the budget guard behave exactly as they do for a single .request(). The one behavioural difference between the modes is the budget:
'sequential'— the budget guard sees every response before the next request goes out, so alimit_usdcap is enforced exactly. Also the right choice for rate-limited upstreams, or when later requests depend on earlier ones completing.'parallel'— faster, but up toconcurrencyrequests are already in flight when the cap is reached, so spend can overshootlimit_usdby up toconcurrency - 1requests.getBudget().spent_usdreports the actual amount paid, so it can exceedlimit_usdafter an overshoot (remaining_usdstill floors at0). Keepconcurrencylow when running close to a hard cap.
Items that arrive after the budget is exhausted stand down to a direct fetch, exactly as a single request would, and are reported as successes carrying meta.bypassed === true.
Batching is currently a client-side fan-out over the existing
POST /proxyendpoint — one payment per item. The API is shaped so that a future server-sidePOST /proxy/batch(one payment and one routing pass for the whole group) can back it without a breaking change.
Use runWithPath() to scope interception in any server framework and createFetch() for explicit route-scoped fetch:
const proxy = ProxyClient(fetchWithPayment, {
mode: 'only',
routes: ['/api'],
limit_usd: 1.25,
});
await proxy.runWithPath('/api', async () => {
const response = await fetch('https://api.example.com/data');
console.log(await response.json());
});
const apiFetch = proxy.createFetch('/api');
const directFetch = proxy.createFetch('/health');SocketClient(fetchWithPayment, options) returns a client for opening paid WebSocket sessions through the Consensus Network. Token acquisition and reconnection are handled automatically.
| Option | Type | Default | Description |
|---|---|---|---|
openTimeoutMs |
number |
12000 |
Milliseconds to wait for the WebSocket connection to open before timing out. |
reconnectIntervalMs |
number |
2000 |
Milliseconds between automatic reconnection attempts. |
defaults |
ConsensusSocketTokenParams |
— | Default token parameters applied to every requestToken() call unless overridden. |
limit_usd |
number |
— | Max WebSocket spend in USD (up to 6 decimals). If next token quote exceeds remaining budget, token request is blocked. |
on_limit_reached |
(budget) => void |
— | Callback fired once when the WebSocket spend limit is reached. |
webSocketFactory |
constructor |
auto-detected | Custom WebSocket constructor (browser WebSocket or ws for Node.js). Auto-detected if not provided. |
WebSocket spend checks use a local quote from the known pricing model (model, minutes, megabytes) before token purchase, so there is no additional price-check round trip.
Token requests accept a model parameter to control how your session is billed:
| Model | Description |
|---|---|
"hybrid" |
Billed by both time and data (default). |
"time" |
Billed by duration only (minutes). |
"data" |
Billed by data transfer only (megabytes). |
import { SocketClient } from '@canister-software/consensus-cli';
const client = SocketClient(fetchWithPayment, {
reconnectIntervalMs: 2000,
});
// Request a session token — pays for a time-based session
const auth = await client.requestToken({
model: 'time',
minutes: 5,
megabytes: 0,
});
// Connect using the token — returns a managed session
const session = await client.connect(auth);
session.on('open', () => console.log('Connected'));
session.on('message', (msg) => console.log('Received:', msg));
session.on('error', (err) => console.error('Error:', err));
session.on('close', () => console.log('Disconnected'));
session.send('hello');
// Close when done
session.close();Target specific proxy nodes for WebSocket sessions:
const auth = await client.requestToken({
model: 'hybrid',
minutes: 10,
megabytes: 100,
nodeRegion: 'eu-west',
nodeExclude: 'node.example.com',
});Sessions reconnect automatically on unexpected disconnects. When reconnecting, SocketClient re-requests a fresh token using the same parameters from the last requestToken() call and re-establishes the WebSocket connection. Set reconnectIntervalMs to control retry pacing.
To stop reconnection, call session.close() — this sets an internal flag that suppresses all automatic retries.
Both requestToken() and connect() support a { safe: true } option that catches errors and returns a result object instead of throwing:
const result = await client.requestToken({ model: 'time', minutes: 1 }, { safe: true });
if (!result.ok) {
console.error('Token request failed:', result.error);
} else {
const session = await client.connect(result.data);
}const state = session.getState();
// { connected: boolean, reconnecting: boolean, closedByCaller: boolean }| Command | Description |
|---|---|
consensus setup |
Create a wallet and register it with the x402 proxy. |
consensus setup --force |
Force re-create the account, resetting any existing configuration. |
consensus help |
Show help message. |
Run consensus setup to initialize your environment. The CLI will:
- Create a wallet using your CDP credentials.
- Generate
.consensus-config.jsoncontaining your wallet and delegation credentials. - Export wallet authorization to the x402 proxy for payment delegation.
- Add
.consensus-config.jsonto.gitignoreautomatically to prevent accidental commits.
⚠️ Never commit.consensus-config.json. It contains sensitive wallet credentials. The setup command adds it to.gitignoreautomatically, but verify this if you use a custom.gitignoresetup.
DO NOT keep large amounts in the proxy-delegated wallet - if the proxy is compromised, your delegation could be at risk. Only fund the wallet with amounts you're comfortable delegating for API payments.
