Proxy & WebSocket SDK
This guide covers the two main clients in @canister-software/consensus-cli:
- ProxyClient — routes outbound HTTP through decentralized proxy nodes with automatic x402 micropayments
- SocketClient — opens paid WebSocket sessions with automatic token acquisition and reconnection
Both clients receive the fetchWithPayment instance you created in Client Setup. That is the only dependency.
Installation
Section titled “Installation”npm install @canister-software/consensus-cliProxyClient
Section titled “ProxyClient”ProxyClient(fetchWithPayment, options) returns a framework-agnostic proxy controller. It routes outbound HTTP through Consensus nodes, supports Express-compatible middleware, and can stand down to direct fetch when a spend limit is reached.
Options
Section titled “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 intercepts fetch() calls transparently. manual exposes req.consensus.fetch() for explicit control. |
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 nodes in a region, for example east-us or west-europe. Matched against the region names in GET /nodes. |
node_domain | string | — | Route through a specific node domain. |
node_exclude | string | — | Never route through these node IDs (comma-separated). server excludes the orchestrator. |
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. |
direct | boolean | true | Send requests to the selected node through the node gateway with a signed ticket. false forces the relayed path through the orchestrator. See Routing a request. |
profiles | Record<string, ProxyProfile> | — | Named execution profiles, compiled locally into anonymous, versioned execution plans. |
profile | string | — | Default profile name from profiles, overridable per request. |
Route Filtering
Section titled “Route Filtering”mode decides how routes is read. The two are exact inverses:
ProxyClient(fetchWithPayment, { mode: 'only', routes: ['/api'] }) // proxy only /apiProxyClient(fetchWithPayment, { mode: 'except', routes: ['/health'] }) // proxy everything but /healthProxyClient(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. Misconfigurations are rejected when the client is constructed, rather than quietly 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) |
Auto Strategy
Section titled “Auto Strategy”In auto mode, ProxyClient intercepts the global fetch() within the request context — 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 fetchapp.use( ProxyClient(fetchWithPayment, { mode: 'only', routes: ['/price'], matchSubroutes: false, strategy: 'auto', cache_ttl: 60, verbose: true, }))
// No changes needed — fetch() is automatically proxied for /priceapp.get('/price', async (_req, res) => { const response = await fetch('https://api.example.com/price') res.json(await response.json())})Manual Strategy
Section titled “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
Section titled “Per-Request Node Selection”Both req.consensus.fetch() and req.consensus.request() accept per-request options as a third argument to override node routing at the call level:
const response = await req.consensus.fetch( 'https://api.example.com/data', { method: 'GET' }, { node_region: 'east-us', cache_ttl: 30 })Batch Requests
Section titled “Batch Requests”batch() runs many proxy requests as one group, either concurrently or strictly one at a time:
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 | Description |
|---|---|---|
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". |
signal | — | An AbortSignal that stops dispatching further items. |
Items accept a bare URL string or a full request() payload. Any per-request option (profile, cache_ttl, verbose, node_region, node_domain, node_exclude, direct) can be set for the whole batch and overridden on a single item through its options key.
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, an unrecognized mode, a non-positive concurrency. That is a programming error, and finding it halfway through a paid batch would be an expensive way to learn about a typo.
Choosing a mode
Section titled “Choosing a mode”Each item is an independent paid /proxy request, so caching, profiles, node routing, and the spend limit all behave exactly as they do for a single request(). The one behavioural difference between the modes is the budget:
"sequential"— the spend guard sees every response before the next request goes out, solimit_usdis enforced exactly. Also the right choice for rate-limited upstreams."parallel"— faster, but up toconcurrencyrequests are already in flight when the limit is reached, so spend can overshootlimit_usdby up toconcurrency - 1requests. Keepconcurrencylow when running close to a hard cap.
Items that arrive after the limit is reached stand down to a direct fetch, exactly as a single request would, and are reported as successes carrying meta.bypassed === true.
Framework-Agnostic Usage
Section titled “Framework-Agnostic Usage”Use runWithPath() to scope interception in any server framework and createFetch() for explicit route-scoped fetch — no Express required:
const proxy = ProxyClient(fetchWithPayment, { mode: 'only', routes: ['/api'], limit_usd: 1.25,})
// Scope interception to a path in any frameworkawait proxy.runWithPath('/api', async () => { const response = await fetch('https://api.example.com/data') console.log(await response.json())})
// Or create scoped fetch functions directlyconst apiFetch = proxy.createFetch('/api')const directFetch = proxy.createFetch('/health')Spend Limits
Section titled “Spend Limits”Set limit_usd to cap total proxy spend. When the limit is reached, all subsequent requests fall through to direct fetch automatically — nothing throws, nothing breaks:
ProxyClient(fetchWithPayment, { limit_usd: 0.50, on_limit_reached: (budget) => { console.warn(`Proxy spend limit reached. Total spent: $${budget.spent_usd}`) },})SocketClient
Section titled “SocketClient”SocketClient(fetchWithPayment, options) opens paid WebSocket sessions through the Consensus Network. Token acquisition, spend tracking, and reconnection are all handled automatically.
Options
Section titled “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. If the next token quote would exceed the remaining budget, the token request is blocked. |
on_limit_reached | (budget) => void | — | Callback fired once when the spend limit is reached. |
webSocketFactory | constructor | auto-detected | Custom WebSocket constructor. Auto-detected as browser WebSocket or Node.js ws if not provided. |
Billing Models
Section titled “Billing Models”| 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
Section titled “Basic Usage”import { SocketClient } from '@canister-software/consensus-cli'
const client = SocketClient(fetchWithPayment, { reconnectIntervalMs: 2000,})
// 1. Request a session token — payment happens hereconst auth = await client.requestToken({ model: 'time', minutes: 5, megabytes: 0,})
// 2. Connect using the token — returns a managed sessionconst 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 — also suppresses automatic reconnectionsession.close()Node Filtering
Section titled “Node Filtering”Target specific proxy nodes for your WebSocket session at token-request time:
const auth = await client.requestToken({ model: 'hybrid', minutes: 10, megabytes: 100, nodeRegion: 'west-europe', nodeExclude: 'a1b2c3d4e5f6', // a node ID})Reconnection
Section titled “Reconnection”Sessions reconnect automatically on unexpected disconnects. SocketClient re-requests a fresh token using the same parameters from the last requestToken() call, then re-establishes the WebSocket.
To stop reconnection permanently, call session.close() — this sets an internal flag that suppresses all further retry attempts.
// Control retry pacingconst client = SocketClient(fetchWithPayment, { reconnectIntervalMs: 5000, // retry every 5 s})Safe Mode
Section titled “Safe Mode”Both requestToken() and connect() support a { safe: true } option. Instead of throwing on error, they return a result object:
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
Section titled “Session State”Inspect the current connection state at any time:
const state = session.getState()// {// connected: boolean,// reconnecting: boolean,// closedByCaller: boolean,// }CLI Commands
Section titled “CLI Commands”Run consensus with no arguments to open the full-screen TUI, or use a command directly:
| Command | Description |
|---|---|
consensus setup | Set up payment credentials (see below). --force resets an existing setup. |
consensus tunnel http <host[:port]> | Expose a local HTTP server on a public HTTPS URL. |
consensus tunnel tcp <host:port> | Expose a local TCP service. |
consensus proxy fetch <url> | Send one proxied HTTP request. |
consensus proxy start [--port N] | Run a local HTTP forward proxy. |
consensus proxy reverse <host:port> [--port N] | Put a reverse proxy in front of a local service. Alias: consensus reverse-proxy. |
consensus ws token | Get a WebSocket session token. |
consensus ws connect | Open an interactive WebSocket session. |
consensus ip list [--region X] | List nodes with their region, score, and capabilities. |
consensus ip lease <node-id-or-domain> | Pin your traffic to one node, and so to its IP address. |
consensus ip active | Show the node you have leased. |
consensus ip release | Release the leased node. |
consensus help | Show help. |
Common options: --method GET, --region east-us, --cache-ttl 60, --verbose, --json, and for WebSockets --model hybrid|time|data, --minutes 5, --megabytes 50.
During the public beta the network runs in free mode, so you can use every command without credentials. When payment is enabled, run consensus setup once. It offers two modes:
- Self-managed keys (recommended). Enter an EVM private key, a Solana keypair, and optionally the path to an ICP PEM file. They are written as
CONSENSUS_EVM_KEY,CONSENSUS_SVM_KEY, andCONSENSUS_PEM_PATHto your shell profile, and payments are signed locally; keys never leave your machine. Run thesourcecommand setup prints, or open a new shell, to load them. - Managed wallet. Creates a wallet with your Coinbase Developer Platform credentials (
CDP_API_KEY_ID,CDP_API_KEY_SECRET,CDP_WALLET_SECRETin a.envfile). The keys are held by the proxy service.
The CLI keeps its own state, such as your leased node, preferences, and session history, in ~/.consensus/.