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.
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.yamlby default. Point it elsewhere with the--config <path>flag or theNODE_CONFIGenvironment 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_LISTENoverridesserver.listen,NODE_ZS_SELF_EVICTION_ENABLEDoverrideszs.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 toconfig.yaml.
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.
| Key | Default | What it controls |
|---|---|---|
listen | 127.0.0.1:9090 | Public 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_listen | 127.0.0.1:9091 | Ops 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_timeout | 30s | Request-read deadline. |
write_timeout | 0s | Keep at 0 — disabled, required for streams. |
idle_timeout | 120s | Keep-alive idle deadline. |
drain_grace | 20s | On 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_timeout | 5m | How 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_timeout | 30s | Final 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. |
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:
| Mode | What 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. |
manual | Load cert_path + key_path from disk at startup; rotate by replacing the files and restarting. |
acme | Provision 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).
| Key | Default | Notes |
|---|---|---|
enabled | false | Master switch. |
ipv4 / ipv6 | auto-detect | Tri-state: omitted = auto, false = never publish, true = require. |
interval | 5m | Detection cadence (minimum 1m). |
ttl | 5m | Published 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.enabled | true when ip_sync.enabled | Also 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.
| Key | Default | Notes |
|---|---|---|
network | mainnet | mainnet | 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. |
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
| Key | Default | Values |
|---|---|---|
level | info | debug | info | warn | error |
format | text | text | 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:
| Provider | What it does |
|---|---|
openai_passthrough | Any OpenAI-compatible base URL (real OpenAI, vLLM, Ollama, hosted gateways, Anthropic's OpenAI-compat endpoint). Configure llm.openai.base_url + api_key. |
lmstudio | Local LM Studio. base_url defaults to http://localhost:1234/v1; queries /api/v1/models for metadata. |
llamacpp | An externally-running llama-server. base_url defaults to http://127.0.0.1:8080/v1; queries /props for metadata. |
kronk | An 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. |
local | The node supervises a llama-server child over loopback (recommended for self-hosted GPUs). See llm.local below. |
vertexai | Google 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.
| Key | Default | Notes |
|---|---|---|
base_url | https://api.openai.com/v1 | Upstream 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. |
timeout | 0s | Keep at 0 for long streams. |
translate_responses_to_chat | provider-dependent | Emulate /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).
| Key | Default | Notes |
|---|---|---|
binary_path | /usr/local/bin/llama-server | macOS dev: /opt/homebrew/bin/llama-server (brew install llama.cpp). |
startup_timeout | 60s | Wait for the child's health check; big GGUFs on cold storage need this. |
host / port | 127.0.0.1 / 0 | Loopback only; 0 = OS-assigned port. |
parallel_slots | 0 | Concurrent 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 0s — keep 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.
| Key | Default | Notes |
|---|---|---|
interval | 30s | Probe cadence. |
timeout | 5s | Per-probe deadline (must be < interval). |
failure_threshold | 2 | Consecutive 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.
| Key | Notes |
|---|---|
provider | "" | comfyui | comfyui_cloud. |
comfyui.base_url | e.g. http://127.0.0.1:8000. |
comfyui.data_dir | Optional 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
| Key | Default | Notes |
|---|---|---|
operator_id | 0 | Your on-chain operator id from registration (0 is reserved). Owner address + NFD are read from the operator box at startup. |
node_id | 0 | This node's per-operator id (0 reserved). Its signing address is read from the node box. |
max_active_tickets | 16 | Default 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
| Key | Default | Notes |
|---|---|---|
ticket_ttl | 5s | Window 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_after | 5m | Fallback 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 optionalcache_read_ratediscounts the cached-read input subset (see below).models[]— per-model entries.pricingoverrides the default; an omitted or emptypricing: {}inherits it; explicit{input_rate: 0, output_rate: 0}is a free model (never inherits).contextdeclares the model's capabilities and size limits (see below).max_active_ticketsoverrides 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.
| Field | What it does |
|---|---|
context_window | Total 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_tokens | The 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_modalities | Content types the model accepts: text, image, audio, video. Omitting implies text-only, which also suppresses feeding generated images back to the model. |
output_modalities | Content types it produces. Usually ["text"]. |
tool_use | Whether the model can call tools. Overrides discovery. |
reasoning | {supported, allowed_efforts, default_effort}. Overrides discovery; gates reasoning replay in the tool loop. |
max_input_images | Per-prompt cap on input images. The node trims the oldest rather than letting the request fail. Enforced locally, not advertised. |
tags | Free-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 anypricingblock) — the USD/1M rate for the cached-read input subset. Omit it and cached reads bill atinput_rate(no change);0makes 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 anypricingblock) — 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 highcache_read_ratethat defaults to your base discount scaled by the input step-up. A request whose prompt reachesthreshold_tokensbills at the high rates for the whole request. With a tier set, raisecontext_windowto 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.
| Key | Default | Notes |
|---|---|---|
output_tokens | 1000 | Bill as if at least this many output tokens were produced, so a tiny response never settles ~0. 0 disables. |
algo_txns | 0 | µ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.
| Key | Default | Notes |
|---|---|---|
enforce_input_budget | true | Reconcile 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_tolerance | 0.10 | Slack 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_count | 256 | Floor 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_iteration | 4000 | Per-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.
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).
| Key | Default | Notes |
|---|---|---|
source | coingecko | Only CoinGecko is supported today. |
refresh_interval | 30s | Fetch cadence. |
max_staleness | 5m | Past this, Reserve omits the price. |
min_algo_usd | 0.01 | Reject quotes below this as unreliable. |
http_timeout | 10s | Per-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, mainnet3628061142. An explicit value always wins. Localnet has no embedded default — set it there. escrow_app_id: 0is a test-isolation hook only (short-circuits payment verification); a0-mode node is undiscoverable in production and can't claim USDC.mempool_poll_timeout(5s) andmempool_poll_interval(100ms) bound how long the node waits for theopen()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.
| Bucket | Default rps / burst | Scope |
|---|---|---|
reserve_per_ip | 2 / 5 | /v1/zs/reserve per IP. |
reserve_per_account | 5 / 10 | Per Algorand payer address (escrow enabled). |
llm_per_ip | 5 / 20 | Chat + responses per IP. |
discovery_per_ip | 20 / 40 | /v1/models, /v1/zs/details, etc. |
relay_per_ip | 10 / 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.
| Key | Default | Notes |
|---|---|---|
enabled | true | Master switch. false disables the whole subsystem (including zs_get_time). |
max_iterations | 20 | Absolute 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_iterations | 3 | Consecutive repeated-call rounds tolerated before the loop stops (clamped [1,10]). Node-internal; not advertised. |
web_search.enabled | true | Per-tool toggle for zs_web_search. |
web_search.max_results | 10 | DuckDuckGo HTML; 1..25. |
web_search.safe_search | moderate | strict | moderate | off — an unknown value is a hard startup error. off returns unfiltered results; set it only if that's your intent. |
web_search.timeout | 15s | Per-search deadline. |
web_read.enabled | true | Per-tool toggle for zs_web_read. |
web_read.max_bytes | 65536 | Truncation ceiling on the markdown handed to the model (keeps fetched pages within its context). |
web_read.max_download | 4194304 | Ceiling 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_concurrent | 4 | How 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.timeout | 15s | Per-fetch deadline. |
web_read.allow_private_targets | false | Localnet/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. |
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(orNODE_ZS_RELAY_ONLY=true) — pure relay: text and image providers empty, nozs.models, a non-zeroescrow_app_idrequired. Prompt routes 404; only relay and discovery GETs are served.allow_private_relay_targets(defaultfalse) — 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. Settrueonly 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.
| Key | Default | Notes |
|---|---|---|
enabled | true | Disable with false. |
interval | 5m | Re-check cadence. |
threshold | 3 | Consecutive missing reads before exiting (rides out a reorg). |
Env: NODE_ZS_SELF_EVICTION_{ENABLED,INTERVAL,THRESHOLD}.
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.
| Key | Default | Notes |
|---|---|---|
poll_interval | 5m | How often to read the balance. Set 0 to disable the poller. |
warn_below_algos | 5 | Emit 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.
| Value | Behavior |
|---|---|
<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) andsettlement_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).
| Key | Default | Notes |
|---|---|---|
mode | none | none | stub | nvidia-cc-tdx | nvidia-cc-snp. |
attestation.nras_url | https://nras.attestation.nvidia.com | NVIDIA Remote Attestation Service. |
attestation.refresh_interval | 1h | /v1/zs/attestation serves 503 once stale past 2× this. |
attestation.evidence_cache_path | — | Disk 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}.
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)
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.
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.