Skip to main content

Node CLI reference

zs-node is a daemon configured by config.yaml plus env secrets. Its command line falls into three groups:

  • The daemon invocation itself (config file, a couple of overrides).
  • A small set of admin subcommands — 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), settlement (inspect and repair the on-chain settlement ledger), tee (write or verify the measured compose file for a confidential-mode deploy), install-service / uninstall-service (the service unit), and version.
  • Operations that are not in the CLI, notably operator/node registration and staging, which you do from the operator dashboard at operator.zerosignal.ai.
warning

The command set is still evolving. The subcommands and flags below are real in current builds, but names, flags, and output formats may change before the CLI is declared stable. Treat each subcommand's -h / --help as the final word, and check it before scripting against it.

Running the daemon​

With no subcommand, zs-node runs the daemon. It loads ./config.yaml unless you pass --config or set NODE_CONFIG.

# 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

Almost everything else is set in config.yaml. Many keys also take a NODE_* environment override, though some are file-only, and the signing mnemonic comes from a *_MNEMONIC variable or ZS_MNEMONIC_URLS (see Configuration). The daemon takes three 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

Run the daemon 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. Packaging and secrets are covered in Installation.

Admin subcommands​

The subcommands that touch the chain (doctor, nfd-dns, settlement) reuse the daemon's config and keystore, so they work against the network and accounts your config.yaml describes, and take the same --config flag. Each has its own -h / --help:

zs-node init --help
zs-node doctor --help
zs-node nfd-dns --help
zs-node settlement --help
zs-node tee --help
zs-node install-service --help
zs-node uninstall-service --help
zs-node version

init — interrogate a backend and generate a config​

zs-node init interrogates your inference backend (what it is, what it can do, and what it costs) and writes a complete, commented config.yaml 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: a vLLM without --enable-auto-tool-choice 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 against your real config.yaml, then compares what your config declares against what your backend actually does. Run it after a vLLM upgrade, after editing your model catalog, or when 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 1 ALGO boot floor, plus a warning below the 5 ALGO spendable floor that proxies and the web client route on — skipped for a relay-only node), the owner's USDC opt-in, and the listen port.

The comparison finds declarations your backend doesn't honor. A model declared context.tool_use: true whose backend rejects tools[] serves a 4xx to every tool-using client, and the node logs only an upstream LLM rejection, not the declaration behind it. The same goes for a reasoning.allowed_efforts 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 and once through the node's own responses-to-chat translator, and recommends the setting that actually completed. The second turn resubmits the tool result, which is where a strict upstream rejects the translated message shape; a check that /v1/responses returns 200 can't catch that. Skip it with --no-ab.

Serving /v1/responses is a startup requirement: a backend with no Responses route and translate_responses_to_chat: false will not boot. Run doctor before you restart a node to find out from a report rather than an exit.

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

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​

The built-in ACME (Let's Encrypt) flow, and clients resolving your hostname, both need an A/AAAA record in your operator NFD's u.dns. The nfd-dns subcommands write those records. set-apex covers the common case, a single apex record pointing at this machine:

# 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 60-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

zs-node nfd-dns --help covers the rest: the generic nfd-dns set / nfd-dns delete forms for arbitrary record types, the --ttl/--ip-version flags, and the exit codes monitoring scripts can key on (e.g. exit 3 for a partial dual-stack write).

info

DNS only resolves your hostname to an address. The scheme and port clients connect on come from the base URL in your on-chain node record, not from DNS. Seeding the record once at provisioning is enough; to keep it current as your IP changes, enable the ip_sync loop. See Reaching your node for the whole path, and Configuration for the knobs.

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, and the settlement driver normally handles it. settlement is the manual escape hatch for inspecting that ledger and repairing the rare ticket that fell through, such as a request you served but never recorded (a lost write, or 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. With the app id at 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, which reads the on-chain box and prints what would happen without writing or submitting anything. 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.

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.

tee compose — write or verify the measured compose file​

A confidential-mode deployment on dstack / Phala runs from a docker-compose file that the hardware measures byte for byte. zs-node tee compose writes that file for your release, with the published image digest already resolved, so you never assemble it by hand:

# Write docker-compose.yaml for this binary's release (the CPU shape)
zs-node tee compose

# The GPU shape: node + digest-pinned inference engine in one measurement
zs-node tee compose --gpu

# Somewhere else, or to stdout for a pipeline
zs-node tee compose -o deploy/docker-compose.yaml
zs-node tee compose --stdout > docker-compose.yaml

What it does depends on whether the target file already exists, not on a flag. If it's absent, it writes the compose. If it's present, it writes nothing and instead verifies the file against the published release skeletons, reporting which shape it matched. After you edit your config block, re-run the same command to check it.

Before comparing the file to the release skeleton, the verifier lifts out the node's image: line, the NODE_CONFIG_YAML: block, and (GPU shape) the engine's config block under zs-engine-config:. The image: must still name a published release image, the only kind payers accept. Any other change, even a reflowed comment, still deploys and attests, but payers who require attested hardware route elsewhere, and nothing is logged.

--force overwrites an existing file instead of verifying it, keeping the old one as .bak; --image uses an image reference verbatim, resolving nothing; --version resolves the digest for another release tag instead of this binary's own. Passing --image or --version against a file that already exists is refused rather than verified, since there is nothing to verify it against. The whole deployment is walked through in Confidential compute.

install-service / uninstall-service — the service unit​

install-service generates and enables a service that runs this binary with your config, and uninstall-service removes it. On Linux that is a system-level systemd unit and needs root; on macOS it is a per-user launchd agent and does not. Windows is not supported.

sudo zs-node install-service # Linux, first run: drops the secrets template and stops
sudo zs-node install-service # Linux, second run: installs, enables, starts
sudo zs-node install-service --print # show the unit; install nothing
sudo zs-node uninstall-service

zs-node install-service # macOS: per-user agent, no sudo

--config is the absolute path to the config the service runs with, --env-file the EnvironmentFile the unit sources your secrets from (Linux only — that is what the two-run flow above is for), and --bin the binary path baked into the unit (default: the one you are running). The generated unit, the secrets file, and the drain timings it encodes are covered in Installation → Running as a service.

version​

zs-node version

Prints two independently versioned lines: the build identity (release and commit) and the wire protocol version. The protocol version is the one that matters for compatibility: peers filter on its major version. Both are the values your node advertises on /v1/zs/details.

What's not in the CLI​

  • Registration and staging. Registering your operator, adding nodes, setting each node's signing address and base URL, and toggling staging are done in the operator dashboard with your owner wallet. See Registering on-chain and Staging nodes.
  • Encryption keys. The node generates and rotates a short-lived encryption recipient in memory on its own: no key file, no age-keygen, no rotation command. See Encryption & keys.
  • Loading the signing key. The mnemonic comes from the environment (any variable ending in _MNEMONIC, e.g. OPERATOR_SIGNING_MNEMONIC) or a cloud secret manager (ZS_MNEMONIC_URLS). See Encryption & keys and Configuration.

The quick start ties the daemon, dashboard registration, and these admin subcommands into a first end-to-end bring-up.