Skip to main content

Configuration

Your node is driven by a single YAML file plus environment overrides. This page walks the file top to bottom: what each section controls, the knobs you'll actually touch, and their defaults.

The operator/escrow settings live under the zs: section, with NODE_ZS_* environment overrides. It's a reference — for the why behind pricing and the model catalog see Serving models & pricing, and for the signing mnemonic see Encryption & keys.

Generate it: zs-node init

Before writing this file by hand, try the wizard. Point it at your inference backend and it produces a complete, commented config.yaml:

zs-node init --base-url=http://127.0.0.1:8080/v1

It interrogates the backend rather than guessing: it works out which runtime it is (vLLM, SGLang, llama.cpp, LM Studio, Ollama, or a hosted gateway) and sets llm.provider to match; finds the real API root if you gave it the wrong one; checks whether /v1/responses exists (which decides llm.openai.translate_responses_to_chat) and whether streaming reports token usage — a backend that doesn't will be rejected, because the node bills streaming work from that usage and refuses to start without it; and probes each model for tool calling, reasoning, image input, per-prompt image limits, and its output ceiling. It looks up list pricing in the models.dev catalog, applies whatever margin you choose, and checks your on-chain operator and node records, your keystore, your USDC opt-in, and your signing balance.

info

The probe sends real requests — tiny ones, one to sixteen output tokens each. That is the only way to learn what your deployment does rather than what the model can do in principle: a vLLM started without --enable-auto-tool-choice cannot make tool calls no matter what the model card says, and the wizard will tell you so in as many words. Against a local backend it just runs; against a metered endpoint it asks first. Use --probe=passive to read metadata only.

The generated file is validated before it is written — the wizard loads it exactly as the daemon would and refuses to emit anything that wouldn't start. Every value is annotated with where it came from, and anything surprising becomes a note in the file header.

# Re-run against an existing config; your own rates and settings are kept.
zs-node init --from=config.yaml --dry-run

# A paid gateway, two models, resold at a 25% margin.
zs-node init --base-url=https://api.z.ai/api/paas/v4 --models=glm-4.6 --margin=25

# Unattended.
zs-node init --base-url=http://vllm:8000/v1 --network=mainnet \
--operator-id=7 --node-id=1 --non-interactive --yes

It never writes an API key or a mnemonic into the file, never submits a transaction, and always keeps a .bak copy before overwriting. Run zs-node init -h for every flag.

The rest of this page is the reference for what it produces — and for everything it doesn't set, which keeps its built-in default.

How config is loaded

  • File. The node reads ./config.yaml by default. Point it elsewhere with the --config <path> flag or the NODE_CONFIG environment variable.
  • Environment overrides. A curated set of settings can be overridden by an environment variable with the NODE_ prefix following the YAML path — e.g. NODE_SERVER_LISTEN overrides server.listen, NODE_ZS_SELF_EVICTION_ENABLED overrides zs.self_eviction.enabled. This is a hand-maintained allowlist (not every key has an env override — several timeouts, the oracle, min_charge, and rate-limit buckets are file-only), and the environment always wins over the file for the keys that are wired.
  • Secrets come from the environment, never the file. API keys (NODE_LLM_OPENAI_API_KEY), the algod token (NODE_ALGOD_TOKEN), and the signing mnemonic (OPERATOR_SIGNING_MNEMONIC / ZS_MNEMONIC_URLS) are read from the environment or a secret manager. Don't commit them to config.yaml.
warning

Three timeouts must stay 0 for streaming. server.write_timeout, llm.openai.timeout, and llm.vertexai.timeout all default to 0s (disabled). Inference responses are long-lived SSE streams; a non-zero value here will cut a generation off mid-stream. Leave them at 0.

server — listeners, timeouts, TLS

Controls the two HTTP listeners and how the node is exposed.

KeyDefaultWhat it controls
listen127.0.0.1:9090Public listener (prompts, reserve, discovery). Loopback by default (safe); a serving node binds all interfaces — use :9090 (dual-stack IPv4+IPv6; 0.0.0.0 is IPv4-only) and serve HTTPS with tls.mode: acme, or terminate TLS at a single reverse proxy. zs-node init writes this. Override with NODE_SERVER_LISTEN.
private_listen127.0.0.1:9091Ops listener for /healthz, /livez and /metrics, off the public port and exempt from rate limiting. Set "" to colocate them on listen. Override with NODE_SERVER_PRIVATE_LISTEN.
read_timeout30sRequest-read deadline.
write_timeout0sKeep at 0 — disabled, required for streams.
idle_timeout120sKeep-alive idle deadline.
drain_grace20sOn SIGTERM, how long reservations issued before the shutdown are still honored, so a request already in flight lands. New reservations are refused immediately regardless. Override with NODE_SERVER_DRAIN_GRACE.
drain_timeout5mHow long shutdown waits for inference already running to finish. Size it for your worst-case single response — the point is never to cut off work a payer has paid for — but keep it at or under whatever per-request timeout fronts the node, since there's no value in waiting for a stream your own proxy will cut. 0 opts out of draining entirely, and also disables drain_grace (the grace hold runs inside the same wait). Override with NODE_SERVER_DRAIN_TIMEOUT.
shutdown_timeout30sFinal connection-close budget after inference has drained. Unlike the other two this has no 0 opt-out — 0 is treated as 30s, since a connection that never closes would otherwise hang the process past your supervisor's deadline. Override with NODE_SERVER_SHUTDOWN_TIMEOUT.
warning

Your supervisor's kill deadline must exceed drain_grace + drain_timeout + shutdown_timeout (5m50s with the defaults) or it force-kills the node mid-drain. See Graceful shutdown.

The private listener is never TLS-wrapped — it's loopback plaintext by design. In YAML, quote a leading-colon value like ":9091" (the colon is a mapping marker). The full set of routes each listener serves is in the Endpoint reference.

server.tls

TLS for the public listener. Three modes:

ModeWhat it does
off (default)Plain HTTP (loopback-only default). Prefer acme to serve HTTPS directly; or terminate TLS at a single reverse proxy in front of one node.
manualLoad cert_path + key_path from disk at startup; rotate by replacing the files and restarting.
acmeProvision and renew a Let's Encrypt certificate automatically (detailed below).

The acme mode provisions and renews a Let's Encrypt certificate in the background via DNS-01, solved against your operator's NFD u.dns (the only supported DNS provider). It requires algod.network{testnet, mainnet} and the operator's NFD app id (loaded from chain when zs.escrow_app_id is set). Knobs: cache_dir (writable, persists the ACME account + certs), email (optional Let's Encrypt contact — defaults to operator_<id>@algo.xyz on mainnet / @dotalgo.io on testnet, and need not be deliverable), domains (optional; auto-derived from the NFD apex), directory_url (defaults to LE production; a staging endpoint exists for testing the wiring), propagation_delay (30s), propagation_timeout (6m).

When TLS is on, bind listen to a public (non-loopback) address — the node is meant to face clients directly. See Endpoints for the public-URL requirements.

server.tls.ip_sync

Opt-in background loop (default off) that detects the node's public IP and rewrites the A/AAAA record on the operator's NFD when it changes — at the apex, or under zs.nfd_record_name. Only runs when tls.mode is manual or acme and algod.network is testnet/mainnet (same NFD preconditions as ACME).

KeyDefaultNotes
enabledfalseMaster switch.
ipv4 / ipv6auto-detectTri-state: omitted = auto, false = never publish, true = require.
interval5mDetection cadence (minimum 1m).
ttl5mPublished record TTL. Matches interval by default so a moved IP isn't served from a resolver cache for longer than it takes the loop to notice.
url.enabledtrue when ip_sync.enabledAlso keeps this node's on-chain NodeRecord.baseUrl in sync (the URL lives on the node box, keyed by (operator_id, node_id)). Requires tls.mode=acme + zs.escrow_app_id.

Env: NODE_SERVER_TLS_IP_SYNC_{ENABLED,IPV4,IPV6,INTERVAL,TTL,URL_ENABLED}.

algod — Algorand connection

Selects the chain the node verifies payments and submits settlements against. Setting network alone is enough — it picks a sensible default endpoint (nodely.dev on a public network, http://localhost:4001 for localnet) and token.

KeyDefaultNotes
networkmainnetmainnet | testnet | localnet. Omitting the whole block boots on mainnet with its canonical escrow app id — that's the zero-config path.
endpoint""Optional override for a private/paid algod. Setting a raw endpoint with no network stays fully manual: no network default is stamped, so nothing gap-fills the escrow app id.
token""Optional override — prefer NODE_ALGOD_TOKEN.
info

Your algod must be able to observe the mempool. Admission verifies the payer's escrow open() via a pending-transaction lookup before it confirms. Public RPC (Nodely / AlgoNode) works out of the box; a self-hosted algod must be participating in consensus or have ForceFetchTransactions: true set.

logging

KeyDefaultValues
levelinfodebug | info | warn | error
formattexttext | json

llm — text inference backend

Picks the upstream that runs your text models. One provider per node. Omit the whole llm: block for an image-only node.

llm.provider is one of:

ProviderWhat it does
openai_passthroughAny OpenAI-compatible base URL (real OpenAI, vLLM, Ollama, hosted gateways, Anthropic's OpenAI-compat endpoint). Configure llm.openai.base_url + api_key.
lmstudioLocal LM Studio. base_url defaults to http://localhost:1234/v1; queries /api/v1/models for metadata.
llamacppAn externally-running llama-server. base_url defaults to http://127.0.0.1:8080/v1; queries /props for metadata.
kronkAn externally-running Kronk server — a multi-model llama.cpp pool. base_url defaults to http://127.0.0.1:11435/v1; queries /v1/kronk/* for metadata and each model's provenance source, which its bare GGUF-file-stem ids can't otherwise satisfy.
localThe node supervises a llama-server child over loopback (recommended for self-hosted GPUs). See llm.local below.
vertexaiGoogle Vertex AI's OpenAI-compatible surface, authenticated via Application Default Credentials (no static key). See llm.vertexai.
"" (empty)No text inference. Combined with image_llm this is image-only mode; combined with zs.relay_only: true it's relay-only mode.

llm.openai

Connection settings shared by openai_passthrough, lmstudio, llamacpp, and kronk.

KeyDefaultNotes
base_urlhttps://api.openai.com/v1Upstream API root. Include the version segment (usually /v1) yourself. For lmstudio/llamacpp/kronk, leave empty to take their loopback default.
api_key""Prefer NODE_LLM_OPENAI_API_KEY env. Optional for lmstudio/llamacpp/kronk — but for kronk, required once KRONK_AUTHORIZATION_MODE is anything but open, and it must be an admin token for the node to read model provenance.
timeout0sKeep at 0 for long streams.
translate_responses_to_chatprovider-dependentEmulate /v1/responses on top of /v1/chat/completions. Defaults true for llamacpp (llama-server has no Responses route), false for lmstudio, kronk, and openai_passthrough. Set true only for an upstream that lacks /v1/responses.

llm.local

When provider: local, the node starts and restarts a llama-server child process. Install llama-server once; the node supervises it (5 restart attempts in 60s, then permanent failure). Note: there is one model per node by design — declaring more than one entry is a config error, and there is no Hugging Face pull yet (point model_path at a local GGUF).

KeyDefaultNotes
binary_path/usr/local/bin/llama-servermacOS dev: /opt/homebrew/bin/llama-server (brew install llama.cpp).
startup_timeout60sWait for the child's health check; big GGUFs on cold storage need this.
host / port127.0.0.1 / 0Loopback only; 0 = OS-assigned port.
parallel_slots0Concurrent decode sessions; 0 derives from zs.max_active_tickets.
models[]Exactly one: id, model_path (GGUF), context_window, gpu_layers (0–99), threads, extra_args[].

KV-cache memory scales with context_window × parallel_slots, so raising zs.max_active_tickets raises memory use. Do not put --parallel, -np, --cont-batching, or -c in extra_args — those are derived and rejected at startup.

llm.vertexai

When provider: vertexai, authenticate via Application Default Credentials (Cloud Run / GKE Workload Identity / GCE SA, or gcloud auth application-default login for dev) — there is no api_key. Required: project, location (e.g. us-central1), and models[] (each an publisher-prefixed id like google/gemini-2.5-flash plus context_window). timeout defaults to 0skeep it there for streams. /v1/responses is emulated automatically.

llm.health_check

Always on; this block only tunes cadence. The node probes the provider's discovery endpoint so /v1/zs/details advertises only models the backend is actually serving, and /v1/zs/reserve returns 503 provider_unavailable while the backend is down.

KeyDefaultNotes
interval30sProbe cadence.
timeout5sPer-probe deadline (must be < interval).
failure_threshold2Consecutive failures before the backend is marked down.

local and vertexai derive their model list from config and are always reported available.

image_llm — image backend (optional)

Independent of llm — run both, either, or just images. When unset, the node 404s the image routes and no model may declare an image: block.

KeyNotes
provider"" | comfyui | comfyui_cloud.
comfyui.base_urle.g. http://127.0.0.1:8000.
comfyui.data_dirOptional absolute path to the ComfyUI base dir so the node cleans up generated files. Override with NODE_IMAGE_LLM_COMFYUI_DATA_DIR.

Each image-capable model in zs.models[] declares its own image: block (backend, image_rate, max_n, defaults, ComfyUI template). See Serving models & pricing for the pricing model.

zs — operator identity, pricing, and behavior

This is the largest section.

Identity and concurrency

KeyDefaultNotes
operator_id0Your on-chain operator id from registration (0 is reserved). Owner address + NFD are read from the operator box at startup.
node_id0This node's per-operator id (0 reserved). Its signing address is read from the node box.
max_active_tickets16Default per-model concurrency cap; reserve returns 429 when a model hits its cap. Each model has its own independent pool — override per model under zs.models[].max_active_tickets.
nfd_record_name"" (apex)Optional bare DNS label (e.g. node1) to publish a named record under the parent NFD's u.dns. A long label eats into the 248-byte operator-URL ceiling. Env NODE_ZS_NFD_RECORD_NAME.
owner_addr""Optional override. Normally read from the operator box on chain; pin it only if you need to bypass the chain lookup. Env NODE_ZS_OWNER_ADDR.
signing_addr""Optional override. Normally read from the node box on chain; the node still refuses to start unless the matching mnemonic is loaded via the keystore. Env NODE_ZS_SIGNING_ADDR.

The signing mnemonic itself is not a config key — it's provisioned through the keystore (see below and Encryption & keys).

Ticket lifetimes

KeyDefaultNotes
ticket_ttl5sWindow between reserve and the inference POST — not inference duration. A POST after expiry gets 402 ticket_invalid. A long stream on a 5s-TTL ticket still completes normally. Kept tight so an abandoned reserve frees its slot fast.
default_expires_after5mFallback settlement-complete deadline gating the contract's refund_inactive. Per-model override: expires_after. Keep it ≳ your p99 inference duration plus the ~30s settlement watchdog.

Pricing

Rates are USD per 1,000,000 tokens (paste directly from a vendor price card) and are net of the protocol fee — what you receive. The node grosses up the escrowed max_price to cover the fee, so don't inflate your published rates.

  • default_pricing{input_rate, output_rate} applied to any model without its own entry. An optional cache_read_rate discounts the cached-read input subset (see below).
  • models[] — per-model entries. pricing overrides the default; an omitted or empty pricing: {} inherits it; explicit {input_rate: 0, output_rate: 0} is a free model (never inherits). context declares the model's capabilities and size limits (see below). max_active_tickets overrides the global pool.

models[].context

What the node advertises about a model, and the reserve-time ceiling it enforces. Every field is optional, but the two sizing fields are worth setting explicitly.

FieldWhat it does
context_windowTotal tokens the model accepts, input plus output. Reserves are sized against it. Most backends publish it and the node reads it automatically — vLLM and SGLang via max_model_len, llama.cpp and LM Studio via their own metadata, Vertex from its model list. Declare it only for gateways that publish no metadata (OpenAI, xAI), or to enforce something tighter. On a self-hosted runtime, prefer leaving it out: a hand-declared value overrides the real --max-model-len your server is running with.
max_output_tokensThe model's output ceiling. Must be below context_window. See Set an output ceiling — leaving it out caps long answers at a quarter of the context window (up to 32,768), and lets over-large requests through to your backend.
input_modalitiesContent types the model accepts: text, image, audio, video. Omitting implies text-only, which also suppresses feeding generated images back to the model.
output_modalitiesContent types it produces. Usually ["text"].
tool_useWhether the model can call tools. Overrides discovery.
reasoning{supported, allowed_efforts, default_effort}. Overrides discovery; gates reasoning replay in the tool loop.
max_input_imagesPer-prompt cap on input images. The node trims the oldest rather than letting the request fail. Enforced locally, not advertised.
tagsFree-form labels, merged with any from the model's HuggingFace card.

Run zs-node doctor after editing this block — it re-probes the backend and reports where your declarations and its actual behavior disagree.

  • cache_read_rate (optional, inside any pricing block) — the USD/1M rate for the cached-read input subset. Omit it and cached reads bill at input_rate (no change); 0 makes them free. It only takes effect when the upstream reports a cached count — vLLM needs --enable-prompt-tokens-details; LM Studio / Ollama don't report one, so it's a no-op there. See Serving models & pricing.
  • long_context (optional, inside any pricing block) — a surcharge tier for upstreams (xAI/Grok) that charge a higher rate once a prompt reaches a size threshold. {threshold_tokens, input_rate, output_rate} are required (the high rates must be ≥ their base counterparts), plus an optional high cache_read_rate that defaults to your base discount scaled by the input step-up. A request whose prompt reaches threshold_tokens bills at the high rates for the whole request. With a tier set, raise context_window to the model's true max instead of capping it at the threshold — the node re-checks the tier against the prompt actually sent when it bills, so an over-sized reserve doesn't over-charge. See Serving models & pricing.

Set either default_pricing or at least one models[] entry (or both). Full detail — modalities, capability badges, image routes — is in Serving models & pricing.

zs.min_charge

Per-request minimum charge. min_price is the max of two components; settlement clamps amount_charged up to it on non-zero usage. Free models bypass it.

KeyDefaultNotes
output_tokens1000Bill as if at least this many output tokens were produced, so a tiny response never settles ~0. 0 disables.
algo_txns0µALGO network fees (in minTxnFee units of 1,000 µALGO) to recover via the oracle. Recommended 7 — the ~7,000 µALGO the operator absorbs per paid request on a live deployment (2 at open() + 5 at atomic settle()). The settle fee is sized to the payments that actually happen, so a free model costs 2 and a failed request 3. Auto-disabled on free models and while the oracle is down.

zs.reserve

How much of a payer's USDC is locked per request, and whether your node checks that the prompt it receives matches what was reserved for it.

The reserve is sized from the actual request body plus a tool-loop headroom term — not the model's full context window — so a short question against a 1M-context model locks cents rather than dollars. The client computes it; your node caps it at context_window − max_output, floors it at min_input_count, and re-measures the decrypted body against it at inference time.

KeyDefaultNotes
enforce_input_budgettrueReconcile the decrypted prompt against the reserved input_count. Over budget, a plain request is refused before any upstream call (sealed, zero-cost, refunded in full) and a tool loop is cut off at the boundary — still returning a final answer synthesized from the results already gathered. This is your protection against a payer that under-declares. false runs monitor mode: over-budget requests are metered and logged but still served, so you can size the real rate before enforcing. The node logs a startup WARN while it's off.
input_budget_tolerance0.10Slack the measured input may exceed the reserve by before it counts as over budget — absorbs estimator jitter between the client's sizing and your re-measurement. Read clamped to [0, 1].
min_input_count256Floor on a caller's declared input_count, so a one-word prompt can't reserve a degenerate near-zero input leg. A declared context window still caps above it.
tool_headroom_per_iteration4000Per-iteration input-token headroom you advertise on /v1/zs/details. Advisory — your node never adds it and never recomputes it; clients read it and size their own reserve.

Env overrides: NODE_ZS_RESERVE_ENFORCE_INPUT_BUDGET, NODE_ZS_RESERVE_INPUT_BUDGET_TOLERANCE, NODE_ZS_RESERVE_MIN_INPUT_COUNT, NODE_ZS_RESERVE_TOOL_HEADROOM_PER_ITERATION.

A plain turn is never falsely rejected: the client sizes from the same bound your node re-measures with, so the two agree within tolerance. Clients add the headroom term only when the request's tools can grow the context your node measures — your zs_* built-in tools, or a frontier model's server-side tools. Tools the caller executes itself (a coding agent's shell and edit tools, client-side MCP) add none, so reserves from agent-style clients are legitimately much smaller.

info

Sizing tool_headroom_per_iteration. Derive it from what your own tools actually return. zs.builtin_tools.web_read.max_bytes defaults to 64 KiB of markdown — roughly 16,000 tokens for one read, about 4× the default per-iteration headroom. At the shipped defaults the aggregate (max_iterations × tool_headroom_per_iteration) absorbs that, but if you lower zs.builtin_tools.max_iterations without raising this value, tool loops get cut short. Watch zs_reserve_input_budget_over_total{outcome="tool_cutoff"} — see Monitoring & metrics.

zs.oracle

ALGO/USD price feed. Used only so Reserve can express ALGO network fees in USD — it is not in the inference-pricing path, and Reserve tolerates an unhealthy oracle (it just omits algo_usd_price).

KeyDefaultNotes
sourcecoingeckoOnly CoinGecko is supported today.
refresh_interval30sFetch cadence.
max_staleness5mPast this, Reserve omits the price.
min_algo_usd0.01Reject quotes below this as unreliable.
http_timeout10sPer-fetch deadline.

zs.escrow_app_id and mempool polling

The deployed ZeroSignalEscrow app id for your network.

  • On a public network you may omit it — the node falls back to the canonical embedded app id: testnet 765860477, mainnet 3628061142. An explicit value always wins. Localnet has no embedded default — set it there.
  • escrow_app_id: 0 is a test-isolation hook only (short-circuits payment verification); a 0-mode node is undiscoverable in production and can't claim USDC.
  • mempool_poll_timeout (5s) and mempool_poll_interval (100ms) bound how long the node waits for the open() group to appear in algod's pending pool. The interval is latency your callers feel: the first check is immediate, so whenever the payment hasn't propagated yet, a full interval elapses before inference starts. Raising it only reduces algod calls.

zs.rate_limits (optional)

Public-facing throttling. The whole block is optional — absent it, the only admission throttle is max_active_tickets. Recommended on any publicly exposed node. Each bucket is a token bucket; setting rps or burst to 0 disables it. /healthz, /livez and /metrics are always exempt.

BucketDefault rps / burstScope
reserve_per_ip2 / 5/v1/zs/reserve per IP.
reserve_per_account5 / 10Per Algorand payer address (escrow enabled).
llm_per_ip5 / 20Chat + responses per IP.
discovery_per_ip20 / 40/v1/models, /v1/zs/details, etc.
relay_per_ip10 / 20/v1/zs/relay forwards per IP (the transport-privacy hops you carry for others).

Plus idle_eviction (30m) and max_keys (100000). Client IP is read from CF-Connecting-IP > X-Real-IP > rightmost X-Forwarded-For > TCP peer.

zs.builtin_tools

In-loop tools the node advertises on /v1/zs/details and runs locally, aggregating all rounds into one receipt. See Built-in tools for the tool catalog and how they run.

KeyDefaultNotes
enabledtrueMaster switch. false disables the whole subsystem (including zs_get_time).
max_iterations20Absolute ceiling on chat→tool→chat rounds (clamped [1,20]). Applied when the key is omitted; the shipped example sets it to 5. Advertised to clients as max_tool_iterations.
max_stalled_iterations3Consecutive repeated-call rounds tolerated before the loop stops (clamped [1,10]). Node-internal; not advertised.
web_search.enabledtruePer-tool toggle for zs_web_search.
web_search.max_results10DuckDuckGo HTML; 1..25.
web_search.safe_searchmoderatestrict | moderate | off — an unknown value is a hard startup error. off returns unfiltered results; set it only if that's your intent.
web_search.timeout15sPer-search deadline.
web_read.enabledtruePer-tool toggle for zs_web_read.
web_read.max_bytes65536Truncation ceiling on the markdown handed to the model (keeps fetched pages within its context).
web_read.max_download4194304Ceiling on the raw page fetched off the wire, before conversion — a page can be far larger than the markdown it distills to. Over this, the read is refused, not truncated. Must be >= max_bytes (compared on effective values, so an explicit 0 still means "the default"); a smaller value is a hard startup error. Not a memory budget: converting a page peaks at roughly 20–70× the page's size, so raising this raises RAM use by a large multiple. 4 MiB is ~2× the largest real-world article observed.
web_read.max_concurrent4How many pages the node converts at the same time. Peak memory ≈ max_download × 20–70 × this, so it's the lever to lower when the node runs under a memory limit — it costs latency (reads queue) rather than capability, where lowering max_download would make large pages permanently unreadable. 4 suits a node that owns its box; 1 is reasonable in a small container. See Sizing the node process.
web_read.timeout15sPer-fetch deadline.
web_read.allow_private_targetsfalseLocalnet/dev only — leave off in production. Disables the guard that refuses to fetch a URL resolving to a private / loopback / link-local / carrier-NAT address. Your node does its own fetching, so with this on, a crafted URL can probe your LAN or cloud metadata endpoint. Logs a startup WARN when enabled. The web_read counterpart of zs.allow_private_relay_targets.
image_search.*Same shape (enabled, max_results 1..100, safe_search, timeout) but force-disabled — see below.
info

image_search is force-disabled in code — its DuckDuckGo image backend is broken, so the node won't advertise or run it regardless of what you set here until a working provider is wired up.

zs.relay behavior

Every node ships the /v1/zs/relay route, registered whenever a relay directory is available (escrow + algod configured). See Relays.

  • relay_only: true (or NODE_ZS_RELAY_ONLY=true) — pure relay: text and image providers empty, no zs.models, a non-zero escrow_app_id required. Prompt routes 404; only relay and discovery GETs are served.
  • allow_private_relay_targets (default false) — SSRF guard. The relay refuses to dial private/loopback/link-local/CGNAT/ULA targets so a malicious operator can't turn your relay into a LAN prober. Set true only for localnet/dev.

zs.self_eviction

Watchdog (on by default) that re-reads this node's own operator box and exits non-zero if the operator is removed on-chain (admin-evicted or unregistered) so a supervisor surfaces it. Requires escrow_app_id + operator_id.

KeyDefaultNotes
enabledtrueDisable with false.
interval5mRe-check cadence.
threshold3Consecutive missing reads before exiting (rides out a reorg).

Env: NODE_ZS_SELF_EVICTION_{ENABLED,INTERVAL,THRESHOLD}.

info

Eviction of a misbehaving operator is an admin moderation action (evictOperator) — there is no permissionless eviction sweep, and nodes don't run one. self_eviction above is just this node noticing it has been removed and shutting itself down cleanly.

zs.signing_balance

Background poller that reads your hot signing address's ALGO balance so you can alert on it before on-chain transactions start failing — that account fee-pays every settlement, and any ACME / IP-sync update. It publishes zs_signing_balance_algos on /metrics and logs a WARN when the balance drops below the threshold; see Monitoring.

KeyDefaultNotes
poll_interval5mHow often to read the balance. Set 0 to disable the poller.
warn_below_algos5Emit a low-balance WARN below this. Advisory — independent of the hard 1 ALGO boot floor the node enforces at startup.

The poller runs for relay-only nodes too, since they still fee-pay ACME and IP-sync updates.

zs.settlement_db_path

Where the node records served tickets while the background settlement driver drives each through on-chain escrow.settle. On startup, leftover settling entries are reconciled against algod.

ValueBehavior
<path>Durable SQLite (WAL). Recommended for production.
:memory:Ephemeral SQLite (dev only).
""In-memory store, non-durable (dev only; the contract's refund_inactive backstop still protects payer funds).

Related:

  • settlement_lapse_grace_seconds (300) — node-side wait before it force-finalizes a claim the client never acknowledged; must be ≥ the contract's grace default.
  • settlement_retention (720h, i.e. 30 days) and settlement_retention_interval — how long finalized ledger rows are kept before the node auto-purges them, and how often it sweeps. Purging keeps the SQLite file bounded on a busy node.

See The payment flow for the full settlement lifecycle.

tee — confidential mode (opt-in)

Enabling a non-none tee.mode advertises the node as TEE-capable on /v1/zs/details, exposes /v1/zs/attestation, and makes the proxy verify attestation before routing prompts here. A non-none mode requires llm.provider: local — forwarding plaintext to OpenAI/Vertex would defeat the threat model. This is the knobs-only summary; the trust model, hardware requirements, and in-CVM identity bootstrap are in Confidential compute (TEE).

KeyDefaultNotes
modenonenone | stub | nvidia-cc-tdx | nvidia-cc-snp.
attestation.nras_urlhttps://nras.attestation.nvidia.comNVIDIA Remote Attestation Service.
attestation.refresh_interval1h/v1/zs/attestation serves 503 once stale past 2× this.
attestation.evidence_cache_pathDisk path on a CVM-encrypted volume so a brief bounce doesn't drop you from the verified set.

Env: NODE_TEE_MODE, NODE_TEE_ATTESTATION_{NRAS_URL,REFRESH_INTERVAL,EVIDENCE_CACHE_PATH}.

warning

Partially shipped. The config surface, the /v1/zs/attestation endpoint, the tee advertisement, and the proxy-side verifier are all in place, but the only fully wired mode in this build is stub (deterministic, untrusted evidence for laptop dev). Selecting nvidia-cc-tdx or nvidia-cc-snp currently fails the startup check with a clear "not yet implemented" error — real TDX/SEV-SNP quote fetching and NVIDIA EAT minting land in a later slice behind the same config.

A minimal config.yaml

A working passthrough node. This serves one model, fronts an OpenAI-compatible upstream, and lets the node fall back to the canonical escrow app id for its network.

server:
listen: ":9090" # bind all interfaces (dual-stack); override of the loopback default
private_listen: "127.0.0.1:9091"
write_timeout: "0s" # keep 0 for streams (also the default)

# No algod block: mainnet is the default, and it carries the canonical
# escrow app id. Set algod.network only to target a different network.

logging:
level: "info"
format: "text"

llm:
provider: "openai_passthrough"
openai:
base_url: "https://api.openai.com/v1"
# api_key comes from NODE_LLM_OPENAI_API_KEY — never commit it
timeout: "0s" # keep 0 for streams

zs:
operator_id: 42 # your id from registration
node_id: 1 # this node's id
models:
gpt-5.4-mini:
# No `source:` — a frontier id clears the identity gate on its own. A
# self-hosted model would need `source: "hf:org/model"`; see below.
pricing:
input_rate: 0.31 # USD per 1M input tokens, net of the protocol fee
output_rate: 2.50 # USD per 1M output tokens
# cache_read_rate: 0.08 # optional cached-read discount (no-op unless
# the upstream reports a cached count)
min_charge:
output_tokens: 1000
algo_txns: 7 # recover the ~7,000 µALGO network fees per paid request (live deployment)
settlement_db_path: "./settlement.db" # dev value; for the systemd unit use /var/lib/zs-node/settlement.db (see installation.md)
warning

Every model needs a checkable identity, and a missing one fails quietly. An always-on gate drops any model that has neither from the advertised catalog — the node starts fine, /v1/models just comes back short. A frontier id (gpt-5.4-mini, claude-*, gemini-*, grok-*) clears the gate on the id alone. Recognized provider-qualified spellings clear it too — for example, x-ai/grok-4.5 and google/gemini-2.5-pro fold onto their curated bare-id entries. This does not whitelist arbitrary org/model strings. Anything else needs an explicit source: "hf:org/model" — a bare GGUF stem or a custom id has nothing to check. Note the form: it must be the hf:-prefixed repo ref, and a malformed one is a startup error, not a silent drop.

default_pricing on its own is not a substitute: upstream-discovered ids carry no source, so only frontier-whitelisted ones survive. Declare what you intend to serve, or let zs-node init write the catalog for you — then zs-node doctor reports models pass the provenance gate when it's right.

Provide secrets through the environment, not the file:

export OPERATOR_SIGNING_MNEMONIC="word1 word2 ... word25"
export NODE_LLM_OPENAI_API_KEY="sk-..."

The signing mnemonic must reach the node at startup — it refuses to start without the mnemonic for its node's signing address. Any environment variable ending in _MNEMONIC is picked up (the label is informational; lookup is by derived address), or use a cloud secret manager via ZS_MNEMONIC_URLS (comma-separated name=url pairs). See Encryption & keys for the full keystore options.

info

For the exact install paths, Docker run lines, and reverse-proxy setup, see Installation. For registering the operator and getting your operator_id / node_id, see Registering on-chain.