Node CLI reference
The node software is, at its core, a long-running daemon. You don't drive it
with a large subcommand tree — you point it at a config.yaml, hand it a few
secrets through the environment, and let it run. What command-line surface exists
falls into three groups:
- The daemon invocation itself (config file, a couple of overrides).
- A small set of admin subcommands that share the daemon's config and
keystore —
init(interrogate a backend and generate aconfig.yaml),doctor(check the config you already have against the live backend and the chain),nfd-dns(bootstrap your NFD DNS records), andsettlement(inspect and repair the on-chain settlement ledger). - Operations that are not in the CLI at all — notably operator/node registration and staging, which you do from the operator dashboard UI.
The command set is still evolving. The subcommands and flags below are real in
current builds, but exact names, flags, and output formats may change before the
CLI is declared stable. Treat each subcommand's own -h / --help as the final
word, and check the help before scripting against it.
In the examples below, the node binary is zs-node.
Running the daemon
With no subcommand, the binary runs the node daemon. It needs one thing: a config
file. By default it looks for ./config.yaml; override that with the --config
flag or the NODE_CONFIG environment variable.
# Default: load ./config.yaml
zs-node
# Explicit config path (flag)
zs-node --config /etc/zerosignal/config.yaml
# Same, via environment
NODE_CONFIG=/etc/zerosignal/config.yaml zs-node
Daemon flags are intentionally few — almost everything is configured in
config.yaml and overridable through NODE_* environment variables (see
Configuration). The notable flags:
| Flag | Purpose |
|---|---|
--config PATH | Path to the YAML config file. Defaults to $NODE_CONFIG, else config.yaml. |
--port N | Listen-port override for quick local testing (0 = use the value from config). |
--times | Log per-request encrypt/decrypt durations — a debugging aid, not for production. |
The daemon is meant to run under a supervisor (systemd, Docker --restart, a
container orchestrator) so it comes back across reboots and so the
self-eviction watchdog — which exits non-zero if your
operator record disappears on-chain — actually surfaces. Configuration, secrets,
and packaging are covered in Installation and
Configuration; this page is just the command surface.
Admin subcommands
The admin subcommands live under the same binary and reuse the daemon's config
and keystore, so they work against whatever network and accounts your
config.yaml already describes — no extra plumbing. Each takes the same
--config flag the daemon does, and each carries its own -h / --help:
zs-node init --help
zs-node doctor --help
zs-node nfd-dns --help
zs-node settlement --help
init — interrogate a backend and generate a config
zs-node init is how most operators get their first config.yaml. Point it at
your inference backend; it works out what the backend is, what it can do, and
what it costs, and writes a complete, commented config that it has already proven
loads.
# Interactive: it asks about anything it can't determine.
zs-node init --base-url=http://127.0.0.1:8080/v1
# Re-interrogate an existing config; your own settings and rates survive.
zs-node init --from=config.yaml --dry-run
# Unattended provisioning.
zs-node init --base-url=http://vllm:8000/v1 --network=mainnet \
--operator-id=7 --node-id=1 --non-interactive --yes
What it determines for you: the runtime kind and matching llm.provider; the
real API root (it corrects a missing /v1 or a non-standard gateway root);
whether /v1/responses is native and whether streaming reports usage; per model
the tool-calling, reasoning, vision, image-cap and output-ceiling settings;
list pricing from the models.dev catalog with your margin;
and — reading the chain — whether your operator/node records exist, your keystore
holds the signing key, the owner is opted into USDC, and the signer is funded.
The probe sends small real requests (1–16 output tokens) so it reports what your
deployment does, not what the model can do in principle — a vLLM without
--enable-auto-tool-choice genuinely can't tool-call, and the wizard says so.
It never writes secrets to the file, never submits a transaction, and always
keeps a .bak before overwriting. Full walkthrough:
Configuration → Generate it.
doctor — check an existing config against the live backend and the chain
Where init writes a config, doctor checks the one you already have. It
runs the same interrogation, but against your real config.yaml, and then does
the thing init can't: it compares what your config declares against what your
backend actually does. Run it after a vLLM upgrade, after editing your model
catalog, or any time a client reports something you can't reproduce.
# The full exam: config, live backend, and chain.
zs-node doctor
# One model, with a raw dump of every request and response.
zs-node doctor --models=glm-4.6 --trace=/tmp/doctor.jsonl
# Test an upgraded backend before you edit the config for it.
zs-node doctor --base-url=http://vllm:8000/v1 --no-chain
# In CI: machine-readable, and warnings count as failures.
zs-node doctor --json --strict
Three sections. Config loads your file with the daemon's own loader and
checks the things that fail at startup — the upstream API key, the base_url
shape, the identity gate, pricing, and any NODE_* variable that overrides the
file at runtime. Backend fingerprints the backend, probes the endpoint matrix
(including whether streaming reports usage, which the node hard-requires at
boot), and probes each configured model. Chain covers your operator and node
records, the keystore signing key, the signer's ALGO float, the owner's USDC
opt-in, and the listen port.
The comparison is the part worth running for. A model advertised as
context.tool_use: true whose backend rejects tools[] serves a 4xx to every
tool-using client, and nothing in your logs says so — from the node's point of
view it faithfully advertised what you told it. Same for a
reasoning.allowed_efforts list containing a tier your backend rejects, or a
max_input_images above the backend's real cap.
doctor also settles translate_responses_to_chat empirically. It runs the
same two-turn tool conversation twice — once against native POST /v1/responses,
once through the node's own responses-to-chat translator — and recommends the
setting that actually completed. The second turn, which resubmits the tool
result, is where a strict upstream rejects the translated message shape, so this
answers a question "does /v1/responses return 200" cannot. Skip it with
--no-ab.
--trace=PATH writes every HTTP exchange as JSONL — one object per line with the
probe step, the model, the status, the latency, and the full request and
response bodies (SSE framing intact). It is meant to be read, grepped, or handed
to someone (or something) that can read it for you:
zs-node doctor --trace=/tmp/doctor.jsonl
jq -r 'select(.step|startswith("tool_use")) | "\(.step) \(.status)"' /tmp/doctor.jsonl
jq 'select(.step=="reasoning.baseline") | .response_body' /tmp/doctor.jsonl
Credentials are redacted from the trace, and everything the probe sends is
synthetic — a fixed "hi" prompt, a synthetic tool schema, a 32×32 test image —
so no prompts or completions from your users are in the file. It does contain your
backend URL and the upstream's error text, which sometimes carries an upstream
request id. It's written 0600; read it before you paste it anywhere.
Against a non-local backend the probe costs a few small requests per model, so it
asks first; --yes skips the prompt and --probe=passive reads metadata only.
Unlike zs-proxy doctor, this one's exit code is meaningful, so you can
script it: 0 all checks passed (warnings allowed), 1 at least one failure,
2 a usage error or a config that wouldn't load. --strict promotes warnings to
failures.
nfd-dns — bootstrap your NFD DNS records
When you front your node with the built-in ACME (Let's Encrypt) flow, or you want
clients to resolve your hostname, your operator's NFD needs an A/AAAA record
in its u.dns. The nfd-dns subcommand family writes those records for you. The
common case — a single apex A record pointing at the machine — is the set-apex
one-shot:
# Auto-detect this machine's public IPv4 and write the apex A record
zs-node nfd-dns set-apex auto
# Detect both families and write A + AAAA in one transaction
zs-node nfd-dns set-apex --ip-version=both auto
# Explicit IP (skip the public-IP echo round trip)
zs-node nfd-dns set-apex 203.0.113.1
# RR mode: write the record under a label inside the parent NFD's u.dns,
# matching zs.nfd_record_name (so node1.<nfd>.algo.xyz resolves without
# owning a separate segment NFD)
zs-node nfd-dns set-apex --name=node1 auto
# Loopback model: write A 127.0.0.1 (DNS returns loopback globally; each user
# reaches their own local node, and a public cert still validates)
zs-node nfd-dns set-apex localhost
The positional argument selects what gets written: auto (detect this host's
public IP via https://api64.ipify.org over an IPv4/IPv6-pinned dialer),
localhost (the loopback model), or an explicit <ip> literal. set-apex picks
the right record type from the address family (IPv4 → A, IPv6 → AAAA) and uses
a 300-second TTL by default, matching the ip_sync loop that maintains the same
record (--ttl overrides it). --name defaults to your configured
zs.nfd_record_name (apex @ when that's unset); pass it explicitly to seed a
named record.
Two lower-level forms round out the family for inspecting and for record types beyond the apex A/AAAA:
# List the operator NFD's current u.dns records
zs-node nfd-dns list
# Remove the apex A record
zs-node nfd-dns delete --name=@ --type=a
For the full surface — including the generic nfd-dns set / nfd-dns delete
forms for arbitrary record types, the --ttl/--ip-version flags, and the exit
codes that monitoring scripts can key on (e.g. exit 3 for a partial dual-stack
write) — run zs-node nfd-dns --help.
DNS only needs to resolve your hostname to an address. The scheme and port
clients connect on come from your operator URL on chain (the base URL in your node
record), not from DNS — so u.dns just needs an A/AAAA record at the apex.
Bootstrapping the record once at provisioning is enough; to keep it current as
your IP changes, enable the ip_sync loop instead (see
Configuration and
How the network works).
settlement — inspect and repair the ledger
Your node keeps a local settlement ledger (a SQLite database at
zs.settlement_db_path) tracking every ticket from served through settled.
Normally the settlement driver handles this for you. The settlement subcommand is
the manual escape hatch for inspecting that ledger and repairing the rare ticket
that fell through — for example a request you served but never recorded (a lost
write, a ledger restored from an old snapshot).
It reads — and, for import, writes — the same SQLite ledger the running
daemon uses. SQLite WAL makes the concurrent access safe, so you don't have to
stop the node. Two preconditions:
- The ledger-reading subcommands (
list,get,import) needzs.settlement_db_pathpointing at a durable SQLite file — the in-memory store lives inside the daemon's process and a separate CLI can't reach it. (refund-inactiveandsweepoperate on the on-chain boxes directly and don't open the local ledger, so they don't need this.) - For any subcommand that touches the chain (
import,refund-inactive,sweep),zs.escrow_app_idmust be non-zero and the signing mnemonic must be loaded. Settlement is an on-chain action — with the app id set to0there is no escrow to settle against, and the subcommand refuses to run.
# List ledger rows (optionally filter by status)
zs-node settlement list
zs-node settlement list --status=failed
# Dump one entry in full, by base64 ticket id
zs-node settlement get --ticket-id=<base64>
The repair commands all support --dry-run — they read the on-chain box and print
what would happen, writing and submitting nothing — so preview first:
# Recover a ticket the node served but never recorded: re-insert its
# "served" ledger row from the on-chain box, then let the driver settle it
zs-node settlement import --ticket-id=<base64> --dry-run
zs-node settlement import --ticket-id=<base64>
# Past the settle deadline, the escrow belongs to the payer. Close out a
# missed ticket on-chain (refunds the payer's USDC + box MBR)
zs-node settlement refund-inactive --ticket-id=<base64> --dry-run
zs-node settlement refund-inactive --ticket-id=<base64>
import re-inserts the served row for a ticket whose on-chain box is still
OPEN; the running node's driver then posts the operator-side settle(). By
default it charges the ticket's on-chain max_price — pass
--amount-charged=<microUSDC> if you know the actual usage was lower. It refuses
(with an explanation) when the box is gone, isn't OPEN, belongs to a different
operator, or is too close to its deadline. refund-inactive calls the contract's
refundInactive(ticket_id) from your signing key for a ticket whose settle
deadline has already passed — past that point the contract gates settle() shut,
so the operator can't be paid and the funds are the payer's. Both need the
signing-key mnemonic loaded in the keystore.
For the complete flag list — including the sweep form that walks every
non-finalized ticket box and clears the stale ones, the latency/throughput metric
flags on import, and the exit-code conventions — run
zs-node settlement --help.
What's not in the CLI
Some operations you'd expect to find here are deliberately handled elsewhere:
- Operator and node registration, and staging. You register your operator, add nodes, set each node's signing address and base URL, and toggle the staging flag from the operator dashboard UI — by connecting your owner wallet, not by running a CLI subcommand. See Registering on-chain for what those records contain, and Health & compatibility for how staging works.
- Generating or rotating an encryption key. There's nothing to generate. The
node creates a short-lived ephemeral encryption recipient in memory and rotates
it automatically — no key file, no
age-keygen, no rotation command. See Encryption & keys. - Loading the signing key. The signing mnemonic reaches the node through the
environment (any env var ending in
_MNEMONIC, e.g.OPERATOR_SIGNING_MNEMONIC) or a cloud secret manager (ZS_MNEMONIC_URLS), not via a CLI command. See Encryption & keys and Configuration.
Stuck on something the current CLI doesn't cover? Reach out and we'll walk you through it. The quick start ties the daemon, the dashboard registration, and these admin subcommands together into a first end-to-end bring-up.