Skip to content

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.

Terminal window
npm install @canister-software/consensus-cli

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.

OptionTypeDefaultDescription
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.
routesstring[][]Path rules selected by mode. Query params are ignored; matching is on path only.
matchSubroutesbooleanfalseWhen 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_ttlnumber—TTL in seconds for node-level response caching.
verbosebooleanfalseEnables verbose response metadata from the proxy node.
node_regionstring—Prefer nodes in a region, for example east-us or west-europe. Matched against the region names in GET /nodes.
node_domainstring—Route through a specific node domain.
node_excludestring—Never route through these node IDs (comma-separated). server excludes the orchestrator.
limit_usdnumber—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.
directbooleantrueSend 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.
profilesRecord<string, ProxyProfile>—Named execution profiles, compiled locally into anonymous, versioned execution plans.
profilestring—Default profile name from profiles, overridable per request.

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. Misconfigurations are rejected when the client is constructed, rather than quietly changing what you pay for:

ConfigResult
{ 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)

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 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 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() 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 }
)
OptionDefaultDescription
mode"parallel""parallel" dispatches concurrently; "sequential" runs strictly one at a time, in input order.
concurrency8Max 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.

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, so limit_usd is enforced exactly. Also the right choice for rate-limited upstreams.
  • "parallel" — faster, but up to concurrency requests are already in flight when the limit is reached, so spend can overshoot limit_usd by up to concurrency - 1 requests. Keep concurrency low 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.

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 framework
await proxy.runWithPath('/api', async () => {
const response = await fetch('https://api.example.com/data')
console.log(await response.json())
})
// Or create scoped fetch functions directly
const apiFetch = proxy.createFetch('/api')
const directFetch = proxy.createFetch('/health')

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(fetchWithPayment, options) opens paid WebSocket sessions through the Consensus Network. Token acquisition, spend tracking, and reconnection are all handled automatically.

OptionTypeDefaultDescription
openTimeoutMsnumber12000Milliseconds to wait for the WebSocket connection to open before timing out.
reconnectIntervalMsnumber2000Milliseconds between automatic reconnection attempts.
defaultsConsensusSocketTokenParams—Default token parameters applied to every requestToken() call unless overridden.
limit_usdnumber—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.
webSocketFactoryconstructorauto-detectedCustom WebSocket constructor. Auto-detected as browser WebSocket or Node.js ws if not provided.
ModelDescription
"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,
})
// 1. Request a session token — payment happens here
const auth = await client.requestToken({
model: 'time',
minutes: 5,
megabytes: 0,
})
// 2. 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 — also suppresses automatic reconnection
session.close()

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

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 pacing
const client = SocketClient(fetchWithPayment, {
reconnectIntervalMs: 5000, // retry every 5 s
})

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

Inspect the current connection state at any time:

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

Run consensus with no arguments to open the full-screen TUI, or use a command directly:

CommandDescription
consensus setupSet 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 tokenGet a WebSocket session token.
consensus ws connectOpen 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 activeShow the node you have leased.
consensus ip releaseRelease the leased node.
consensus helpShow 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, and CONSENSUS_PEM_PATH to your shell profile, and payments are signed locally; keys never leave your machine. Run the source command 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_SECRET in a .env file). 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/.