Skip to content

Repository files navigation

Consensus logo

Consensus CLI

TypeScript SDK + CLI for the Consensus Network.
Route outbound HTTP through decentralized proxy nodes and open paid WebSocket sessions with x402 micropayments.

npm version License GitHub stars Status

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.

Installation

npm install @canister-software/consensus-cli

Environment Setup

Create 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_secret

Get your CDP credentials at portal.cdp.coinbase.com.

You can also override the default server with:

CONSENSUS_SERVER_URL=https://your-custom-node.example.com

ProxyClient

ProxyClient(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.

ProxyClient Options

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).

Route Filtering

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 everything

mode 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. mode was 'inclusive' | 'exclusive'. Those names described the proxy's breadth while sitting next to routes, 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.

Proxy Profiles

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.

Auto Strategy (Default)

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());
});

Manual Strategy

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);
});

Per-Request Node Selection

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 Requests

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.

Choosing a mode

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 a limit_usd cap is enforced exactly. Also the right choice for rate-limited upstreams, or when later requests depend on earlier ones completing.
  • 'parallel' — faster, but up to concurrency requests are already in flight when the cap is reached, so spend can overshoot limit_usd by up to concurrency - 1 requests. getBudget().spent_usd reports the actual amount paid, so it can exceed limit_usd after an overshoot (remaining_usd still floors at 0). Keep concurrency low 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 /proxy endpoint — one payment per item. The API is shaped so that a future server-side POST /proxy/batch (one payment and one routing pass for the whole group) can back it without a breaking change.

Framework-Agnostic Usage

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

SocketClient(fetchWithPayment, options) returns a client for opening paid WebSocket sessions through the Consensus Network. Token acquisition and reconnection are handled automatically.

SocketClient Options

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.

Billing Models

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).

Basic Usage

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();

Node Filtering

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',
});

Reconnection

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.

Safe Mode

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);
}

Session State

const state = session.getState();
// { connected: boolean, reconnecting: boolean, closedByCaller: boolean }

CLI Commands

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.

Setup Process

Run consensus setup to initialize your environment. The CLI will:

  1. Create a wallet using your CDP credentials.
  2. Generate .consensus-config.json containing your wallet and delegation credentials.
  3. Export wallet authorization to the x402 proxy for payment delegation.
  4. Add .consensus-config.json to .gitignore automatically to prevent accidental commits.

Security

⚠️ Never commit .consensus-config.json. It contains sensitive wallet credentials. The setup command adds it to .gitignore automatically, but verify this if you use a custom .gitignore setup.

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.

About

Consensus CLI + SDK — TypeScript client for the Consensus x402 HTTP-deduplication proxy network

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages