Skip to main content

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 a config.yaml), doctor (check the config you already have against the live backend and the chain), nfd-dns (bootstrap your NFD DNS records), and settlement (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.
warning

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:

FlagPurpose
--config PATHPath to the YAML config file. Defaults to $NODE_CONFIG, else config.yaml.
--port NListen-port override for quick local testing (0 = use the value from config).
--timesLog per-request encrypt/decrypt durations — a debugging aid, not for production.
info

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.

info

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
warning

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.

info

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) need zs.settlement_db_path pointing at a durable SQLite file — the in-memory store lives inside the daemon's process and a separate CLI can't reach it. (refund-inactive and sweep operate 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_id must be non-zero and the signing mnemonic must be loaded. Settlement is an on-chain action — with the app id set to 0 there 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.
info

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.