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 aconfig.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), andversion. - Operations that are not in the CLI, notably operator/node registration and staging, which you do from the operator dashboard at operator.zerosignal.ai.
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:
| 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. |
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.
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
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).
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) 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. With the app id at0there 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.