Architecture

API Commands

Source: ExaBGP reactor/api/command/, reactor/api/dispatch/ Purpose: Document all API commands for compatibility


Overview

Ze uses verb-first command paths with JSON or text encoding.

Transport Request Metadata

REST and gRPC remain thin adapters over the shared API engine. Execute and Stream pass the caller's context.Context together with trusted auth metadata (Username, RemoteAddr) into the engine; hub wiring then builds pluginserver.CommandContext for dispatcher/accounting use.

Request bodies and plugin RPC payloads do not carry identity fields. Caller identity is injected only by trusted transport wiring.

ExaBGP Differences

Aspect ExaBGP Ze
Syntax styles v4 (action-first) and v6 (target-first) Verb-first only
Encoder json or text (v4), json only (v6) json or text
Peer selectors *, IP, filters ([local-as ...]) *, IP, negated (!IP)
Multi-session filters Supported (draft) Not supported
Forward command Not available request cache forward <id> <selector> for route reflection

Command Verb Taxonomy

Commands follow a verb-first convention: <action> <module> [args...]. The action verb determines the command's behavior; the module implements it.

Verb Purpose Examples
show Read-only display (returns data, exits) show bgp peer <selector> detail, show warnings
set Create or modify set bgp peer X ...
delete Remove delete bgp peer X
update Route operations (announce, withdraw, refresh), firmware, prefix data update system firmware check, update bgp peer * prefix
monitor Long-running auto-refreshing display monitor bgp (TUI dashboard)

Internal dispatch: <action> <module> is dispatched as the module implementing the action. For example, monitor bgp is handled by the BGP module's monitor implementation. Legacy noun-first RPCs (peer list, bgp summary) remain for internal dispatch but user-facing commands use verb-first syntax.

Streaming vs polling: monitor commands keep the display active and auto-refresh. monitor event streams live events line-by-line. monitor bgp polls summary data every 2 seconds and renders a dashboard. Both use the monitor verb because they produce continuously-updating output.

Command Categories

Category Commands
Daemon shutdown, reload, restart, status
Session ack, sync, reset, ping, bye
System help, version, api version
Peer list, detail, capabilities, statistics, set (add/save), delete, teardown, flush
Announce route, flow, vpls, eor, operational
Withdraw route, flow, vpls, watchdog
RIB routes, best, status, clear
Log levels, set (runtime log levels)
Metrics values, list (Prometheus metrics)
Group start, end (batching)
Monitor monitor bgp (TUI dashboard), monitor event (live event streaming), monitor system netlink (kernel events)
Subscribe request subscribe, request unsubscribe (event filtering)
OSPF show ospf neighbor, interface, database, route, border-routers, spf
Reports show warnings, show errors (cross-subsystem operational report bus)

Dispatch keys are YANG paths

The daemon registers each built-in handler under its YANG command path, derived from the tree (LoadBuiltins: d.RegisterWithOptions(wireToPath[wireMethod], ...)). The path — not the wire method — is the dispatch key. Moving a ze:command container in the YANG tree therefore renames the command and breaks anything that sends the old path. Operator commands are safe to migrate; commands a plugin sends by their bare path (over dispatch-command or an interactive plugin CLI session) are a wire break. Before a verb-first migration of a noun-first built-in, grep for senders. See ai/rules/cli.md "Migrating a Built-in Command's Path".

A parent command never swallows a registered child

The daemon serves the LONGEST registered key that prefixes the input and hands the rest to that handler as arguments. A parent registered at a short path would therefore own the whole subtree below it, children another owner registered included. show bgp is a builtin key; show bgp rpki status is a PLUGIN name that Dispatch reaches only after the builtin match fails, so an unguarded match sends the rpki subtree to the summary handler, which reads its first argument as an address family.

matchBuiltinTokens refuses its match when a LONGER prefix of the input is itself a registered command. It asks all three registries the dispatcher resolves from: the builtin keys, the plugin registry, and the subsystem handlers. The test is a registered PATH, never the presence of leftover tokens, because leftovers are how every argument-taking command works: show bgp ipv4 still reaches its handler with the family.

The client-side lookup carries the same rule, and the two are separate because they read different registries. Neither is derived from the other.

Offline fallback (read-only commands without a daemon)

Some read-only show commands must work with no daemon reachableshow crashes (you inspect a crash precisely when the daemon has died) and show host (hardware inventory before the daemon is up). Their owner registers an in-process handler with registry.RegisterOfflineFallback(path, handler). The CLI serves the command from the daemon when it is reachable and calls the fallback only after a connection failure, so the fallback never shadows the daemon. Because cmdutil.RunCommand rejects commands absent from the CLI binary's tree before reaching the daemon path, it makes an exception for paths that have a registered fallback, routing them through so the fallback is reachable. Output is identical to the daemon RPC (both read the same detection library / crash files), so online and offline results match.


Operational Report Bus (ze-show:warnings, ze-show:errors)

The report bus is a single in-process place for Ze subsystems to push operator-visible issues. Any subsystem (BGP, config, interface, plugins) can call report.RaiseWarning / report.RaiseError / report.ClearWarning, and operators query the aggregate via two RPCs.

Severity contract

Severity Lifecycle Storage Cleared by
warning State-based. Condition is currently problematic. Deduped map on (Source, Code, Subject). Bounded by warningCap (default 1024, max 10000). Oldest-by-Updated evicted at cap. ClearWarning(source, code, subject), ClearSource(source), or implicit eviction.
error Event-based. Something already happened. Ring buffer of errorCap events (default 256, max 10000). Oldest evicted on overflow. Never; ring buffer aging only.

Producers MUST pick the right severity. The bus does not auto-promote. "Did anything actually fail or behave unexpectedly?" Yes -> error. No, but it might soon -> warning.

Push API (for subsystems)

Subsystems import internal/core/report and call:

Function Purpose
RaiseWarning(source, code, subject, message string, detail ...map[string]any) Add or refresh an active warning. Deduplicated.
ClearWarning(source, code, subject string) Remove an active warning. No-op if missing.
ClearSource(source string) Remove all active warnings for one source (shutdown cleanup).
RaiseError(source, code, subject, message string, detail ...map[string]any) Append an error event to the ring buffer. No dedup.
Warnings() []Issue Snapshot of all active warnings, most-recently-updated first. Consumed by ze-show:warnings handler.
Errors(limit int) []Issue Recent N error events, newest first. limit 0 or negative returns all retained. Consumed by ze-show:errors handler.

Empty or oversized fields (Source/Code > 64 bytes, Subject > 256, Message > 1024, Detail > 16 keys) are rejected at the boundary with a debug log, protecting the bus from buggy or malicious producers.

Query RPCs

WireMethod Handler Response shape
ze-show:warnings handleShowWarnings in. internal/component/cmd/show/show.go {"warnings": [Issue, ...], "count": N}
ze-show:errors handleShowErrors in. internal/component/cmd/show/show.go {"errors": [Issue, ...], "count": N}
ze-show:traffic handleShowTraffic in. internal/component/traffic/cmd/traffic.go {"interfaces": [...], "count": N} or single interface detail
ze-show:static forwardShowStatic in. internal/plugins/static/cmd_show.go JSON array of configured static routes (proxy to static plugin)
ze-show:policy-routes forwardShowPolicyRoutes in. internal/plugins/policyroute/cmd_show.go JSON array of PBR policy routes (proxy to policyroute plugin)
ze-show:policy-chain handleShowPolicyChain in. internal/component/bgp/plugins/cmd/policy/handler.go {"chains": [{"peer": "...", "name": "...", "import": [{"name": "...", "canonical": "..."}], "export": [...]}]} — per-peer effective filter chains, plain name plus canonical ref
ze-show:policy-test handleShowPolicyTest in. internal/component/bgp/plugins/cmd/policy/handler.go {"direction": "...", "peer": "...", "action": "accept\|reject\|modify", "trace": [PolicyTraceEntry], "text-before": "...", "text-after": "...", "changed-attrs": [...], "wire-changes": ["AS4_PATH suppressed", ...]} — read-only policy dry-run, no forwarding or mutation
ze-show:bmp-sessions forwardShowBMPSessions in. internal/component/bgp/plugins/bmp/cmd_show.go JSON array of BMP receiver sessions (proxy to BMP plugin)
ze-show:bmp-peers forwardShowBMPPeers in. internal/component/bgp/plugins/bmp/cmd_show.go JSON array of BMP monitored peers (proxy to BMP plugin)
ze-show:bmp-collectors forwardShowBMPCollectors in. internal/component/bgp/plugins/bmp/cmd_show.go JSON array of BMP sender collectors (proxy to BMP plugin)
ze-show:bmp-rib forwardShowBMPRib in. internal/component/bgp/plugins/bmp/cmd_show.go BMP-monitored routes (proxy to BMP plugin, dispatches to RIB)
ze-show:rr-status forwardShowRRStatus in. internal/component/bgp/plugins/rr/cmd_show.go {"running": true} (proxy to RR plugin)
ze-show:rr-peers forwardShowRRPeers in. internal/component/bgp/plugins/rr/cmd_show.go JSON array of RR peer states (proxy to RR plugin)
ze-show:system-sockets handleShowSystemSockets in sockets_linux.go {"sockets": [...], "count": N} (Linux only)
ze-show:system-kernel-log handleShowSystemKernelLog in kernel_log_linux.go {"entries": [...], "count": N} (Linux only)
ze-show:system-goroutines handleShowSystemGoroutines in goroutines.go {"total": N, "by-state": {...}, "mode": "..."}
ze-show:tcp-check HandleTCPCheck in. internal/plugins/diag/cmd/tcp_check.go {"host": "...", "port": N, "result": "...", "latency-ms": N}
ze-show:traceroute handleTraceroute in traceroute.go {"target": "...", "hops": [{"hop": N, "addr": "...", "rtt-ms": N, "ttl": N}, ...]}
ze-show:capture-interface handleCaptureInterface in capture_interface_linux.go pcap: {"format": "pcap", "packets": N, "pcap": "base64...", "snap-len": N}; text: {"format": "text", "packets": N, "lines": [...]} (Linux only)
ze-show:system-file-descriptors handleShowSystemFD in fd_linux.go {"total": N, "by-type": {...}, "soft-limit": N, "hard-limit": N} (Linux only)
ze-show:dns-lookup handleDNSLookup in. internal/component/resolve/cmd/show_dns.go {"name": "...", "type": "...", "records": [...], "query-time-ms": N}
ze-show:dns-cache-stats handleDNSCacheStats in. internal/component/resolve/cmd/show_dns.go {"entries": N, "capacity": N, "hits": N, "misses": N, "hit-rate": N, "miss-rate": N, "evictions": N, "expired": N}
ze-show:dns-cache-list handleDNSCacheList in. internal/component/resolve/cmd/show_dns.go {"entries": [...], "count": N}
ze-show:dns-cache-record handleDNSCacheRecord in. internal/component/resolve/cmd/show_dns.go {"entries": [...], "count": N, "filter": "name"}
ze-clear:dns-cache handleClearDNSCache in. internal/component/resolve/cmd/dns.go {"action": "clear-all"}
ze-clear:dns-cache-stats handleClearDNSCacheStats in. internal/component/resolve/cmd/dns.go {"action": "reset-stats"}
ze-clear:dns-cache-record handleClearDNSCacheRecord in. internal/component/resolve/cmd/dns.go {"action": "delete-entry", "name": "...", "removed": N} or {"action": "delete-entry", "name": "...", "type": "...", "found": bool}
ze-show:system-profile handleShowSystemProfile in profile.go {"type": "...", "format": "pprof-base64", "data": "..."}
ze-show:system-memory-map handleShowSystemMemoryMap in memory_map_linux.go {"vm-rss-kb": N, "vm-size-kb": N, ...} (Linux only)
ze-show:system-update handleShowSystemUpdate in. internal/plugins/update-cmd/cmd/show.go {"backend": "ze-self-update"|"gokrazy-ab", "running-version": "...", "remote-version": "...", "update-available": bool, "status": "...", "download-status": "...", "staged-version": "...", "gokrazy-reachable": bool, "gokrazy-features": [...]}
ze-show:system-update-history handleShowSystemUpdateHistory in. internal/plugins/update-cmd/cmd/show.go {"history": [{"timestamp": "...", "from": "...", "to": "...", "result": "..."}], "count": N}
ze-update:system-firmware-check handleFirmwareCheck in firmware.go {"running-version": "...", "update-available": bool, ...} or on gokrazy {"backend":"gokrazy-ab", "status":"unsupported", "message":"updates managed by gokrazy"}
ze-update:system-firmware-download handleFirmwareDownload in firmware.go {"downloaded-version": "...", "status": "complete"}
ze-update:system-firmware-apply handleFirmwareApply in firmware.go {"applied-version": "...", "status": "restarting"}
ze-update:system-firmware-restart handleFirmwareRestart in firmware.go {"status": "restarting"}
ze-update:system-firmware-rollback handleFirmwareRollback in firmware.go {"status": "rolling back"}
ze-show:interface (no args) handleShowInterface in. internal/component/iface/cmd/show_interface.go JSON array of InterfaceInfo. A stray token is refused with the usage text: every subcommand below has its own wire method
ze-show:interface-brief handleShowInterfaceBrief in. internal/component/iface/cmd/show_interface.go {"interfaces": [{name, state, mtu, address?}], "count": N}
ze-show:interface-type (args: <type>) handleShowInterfaceType in. internal/component/iface/cmd/show_interface.go {"interfaces": [InterfaceInfo]} for that type. An unmatched type is refused, and the refusal lists the types the running set has
ze-show:interface-errors handleShowInterfaceErrors in. internal/component/iface/cmd/show_interface.go {"interfaces": [{name, rx-errors, rx-dropped, tx-errors, tx-dropped}]}, only the links with a non-zero counter
ze-show:interface-rate (args: [<name>]) handleShowInterfaceRateCmd in. internal/component/iface/cmd/show_interface.go JSON array of InterfaceRate (all) or single object (named); fields: name, rx-bps, tx-bps, rx-pps, tx-pps, stats
ze-monitor:interface-rate streamInterfaceRate in. internal/component/iface/cmd/interface_rate.go Streaming JSON lines (1/s); optional <name> filter
ze-show:storage-smart handleShowStorageSmart in. internal/component/storage/show.go JSON array of per-device objects: name, transport, healthy, temp-celsius, power-on-hours, error-count, percent-used (NVMe), available-spare (NVMe), smart-enabled, last-checked, last-short-test, last-long-test. Returns error if SMART management not configured.
ze-show:flow-export (args: [<collector>]) handleShowFlowExport in. internal/plugins/flowexport/cmd_show.go
ze-show:traffic-stat (args: [name <interface>]) handleShowTraffic in. internal/component/trafficstat/cmd/traffic.go One-shot aggregated snapshot: {"at": "RFC3339", "severity": "normal\|caution\|danger", "degraded": bool, "interfaces": [{name, rx-bps, tx-bps, rx-pps, tx-pps}], "top-source-ips": [{address, bps}], "top-dest-ips": [{address, bps}], "top-ports": [{port, service, proto, bps, amplification?}], "protocol-mix": [{proto, name, bps, percent}], "history": [float64]}. Optional name <interface> filters the interfaces array. When no collector data: "degraded": true with interface rates only.
ze-monitor:traffic-stat (args: [name <interface>]) streamTraffic in. internal/component/trafficstat/cmd/traffic.go Streaming JSON lines (1/s) with the same shape as ze-show:traffic-stat. Attaches as a consumer on connect, detaches on disconnect (lazy lifecycle). Also registered as a MonitorProvider for full-screen TUI rendering via createTrafficMonitorSession in. cmd/render.go
ze-show:traffic-feature (args: [name <address>]) handleShowTrafficFeature in. internal/component/trafficfeature/cmd/traffic_feature.go Neutral per-source feature snapshot: {"degraded": bool, "top-source-ips": [{address, fan-out, out-in-ratio, port-entropy, new-peer, rare-port, beaconing}]}. out-in-ratio is the string "inf" when a source has no inbound bytes (else a float). Optional name <address> filters to one source. Facts only (no verdict); the anomaly detection family applies judgment.
ze-show:anomaly (no args) handleShowAnomaly in. internal/plugins/anomaly/detect/show.go Recent behavioral anomaly incidents (report-only): {"enabled": bool, "incidents": [{entity, cohort, score, severity, at, fired-features: [{name, z}]}]}. Bounded recent-incident ring; empty until an entity's correlated deviation confirms. enabled is false when the detector is not running. The anomaly/shape responder consumes the underlying anomaly-detect events; this command is the read-only view.
ze-show:anomaly-observe (no args) handleShowAnomalyObserve in. internal/plugins/anomaly/observe/show.go Behavioral anomaly incident LIFECYCLE, newest first: {"enabled": bool, "active-count": N, "incidents": [{id, interface, entity, cohort, fired-features: [{name, z}], score, severity, start-time, end-time, active}]}. end-time is omitted while active is true. It is set when the incident clears, or when the stale timeout finalizes it. A finished incident's duration is readable here and nowhere else. enabled is false when the plugin is not running.
ze-show:anomaly-shape (no args) handleShowAnomalyShape in. internal/plugins/anomaly/shape/show.go Shadow-first responder status: {"enabled": bool, "mode": "shadow"\|"armed", "action": "limit"\|"drop", "kill-switch": bool, "armed-count": N, "armed": [source, ...]}. enabled is false before configuration. In shadow mode (default) nothing is installed; armed sources carry a live per-source firewall action with a timed auto-revert.
ze-show:traffic-usage (args: [name <interface>]) handleShowTrafficUsage in. internal/plugins/trafficusage/show.go
ze-show:pki-certificates handleShowPKICertificates in. internal/component/pki/show.go {"certificates": [CertSummary, ...], "count": N}
ze-show:pki-certificate (args: <name> [pem \| bundle pem \| fingerprint [algo]]) handleShowPKICertificate in. internal/component/pki/show.go

Both warnings/errors handlers accept optional source <name> filter and errors accepts count <N> limit. Return a non-nil empty slice when empty.

Issue JSON shape

Field JSON type Notes
source string Subsystem name (lowercase, short: bgp, config, iface, ...)
code string Kebab-case identifier (prefix-threshold, notification-sent, ...)
severity string "warning" or "error" (MarshalJSON converts the internal uint8 enum to the label)
subject string What the issue is about (peer address, transaction id, file path)
message string Human-readable one-liner
detail object Optional. Omitted via omitempty when nil or empty. Keys and values are producer-defined.
raised RFC 3339 time First appearance
updated RFC 3339 time Most recent raise (warnings advance; errors equal raised)

Day-one BGP producers

Severity Code Subject Detail keys Source function
warning prefix-threshold <peerAddr>/<afi>/<safi> family, count, warning, maximum raisePrefixThreshold in applyPrefixCheck. reactor/session_prefix.go
warning prefix-stale peer address updated RaisePrefixStale in at peer add (and clear at remove). reactor/session_prefix.go, reactor_peers.go
error notification-sent peer address code, subcode, direction=sent raiseNotificationError("sent". ) in Peer.IncrNotificationSent. reactor/session_prefix.go
error notification-received peer address code, subcode, direction=received raiseNotificationError("received". ) in Peer.IncrNotificationReceived. reactor/session_prefix.go
error session-dropped peer address reason raiseSessionDropped in FSM Established->Idle branch when no notification was exchanged. reactor/session_prefix.go, peer_run.go

The session-dropped producer is suppressed when a NOTIFICATION was sent or received during the same session lifecycle: the operator already sees that event in show errors so a duplicate session-dropped would be noise. Tracking is via Peer.notificationExchanged atomic.Bool, reset at the start of each runOnce iteration.

Login banner integration

The Ze CLI login banner reads from the same report bus (filtered by source bgp). One active warning displays the detail line; multiple warnings collapse to a count line pointing at show warnings. This is the single source of truth for "what's wrong right now" across the connect path and the show command.

Capacity limits and env vars

Env var Default Maximum Registered by
ze.report.warnings.max 1024 10000 env.MustRegister in. internal/core/report/report.go
ze.report.errors.max 256 10000 same

Operator values exceeding the maximum are clamped and logged at warn level. Zero or negative values fall back to the default. This prevents an env-var typo from causing memory exhaustion.


Target-First Syntax

Process Lifecycle Commands

request shutdown         # Graceful shutdown
request reboot           # Graceful shutdown + OS reboot (requires root on Linux)
request reload           # Reload configuration
request halt             # Goroutine dump + immediate exit
show status              # Get process status

Session Commands

plugin session ready     # Signal plugin init complete
plugin session ping      # Health check
plugin session bye       # Disconnect

BGP Plugin Configuration

bgp plugin encoding json     # Set event encoding to JSON (default)
bgp plugin encoding text     # Set event encoding to human-readable text
bgp plugin format hex        # Wire bytes as hex string
bgp plugin format base64     # Wire bytes as base64
bgp plugin format parsed     # Decoded fields only (default)
bgp plugin format full       # Both parsed AND wire bytes
bgp plugin ack sync          # Wait for wire transmission
bgp plugin ack async         # Return immediately (default)

Event Subscription Commands

A plugin declares what it CAN handle. The peer's configuration decides what it GETS. A peer-scoped event is delivered to a process when both halves name it: the process subscribed to the event, and the peer's attach process <name> block grants that type in that direction. A peer that attaches no block for a process feeds it nothing.

request subscribe <namespace> event <type> [direction received|sent|both]
request subscribe peer <selector> event <type> [direction ...]
request subscribe plugin <name> <namespace> event <type> [direction ...]
request unsubscribe <namespace> event <type> [direction received|sent|both]

Precedence. The configuration is durable receive authorization and is rebuilt on every config apply. A request subscribe typed at a running daemon is a live capability override: it can add a type the process did not declare at startup only where the peer's block grants that type. It cannot widen the configured grant, and the next config apply discards it.

At plugin ready, and again after every apply, ze reports each peer, process and event type the two halves disagree about. Two lines an operator sees:

event delivery: the config grants an event the plugin never declared  peer=... process=... granted=update-received
event delivery: the plugin declared an event the peer does not grant it  peer=... process=... declared=state

Namespaces: - bgp - BGP protocol events - rib - RIB events (cache, route changes)

BGP event types:

Event Has Direction Description
update UPDATE message
open OPEN message
notification NOTIFICATION message
keepalive KEEPALIVE message
refresh ROUTE-REFRESH message
state Peer state change (up/down)
negotiated Capability negotiation complete
rpki RPKI validation result (from bgp-rpki plugin)
update-rpki UPDATE merged with RPKI validation (from bgp-rpki-decorator)

Plugins may register additional event types via Registration.EventTypes. These are validated at runtime against the dynamic registry.

RIB event types:

Event Description
cache Cache entry events
route Route change events

Examples:

request subscribe bgp event update                              # All peers, both directions
request subscribe bgp event update direction received           # Received only
request subscribe peer upstream1 event update                    # Specific peer
request subscribe peer * event state                            # All peers, state changes
request subscribe peer !upstream1 event update direction sent      # Exclude one peer
request subscribe rib event route                               # RIB route events

Monitor Commands

Stream live events or display live dashboards. All monitor commands follow verb-first syntax: monitor <module> [args...].

monitor event                                                # All events, all peers
monitor event peer <addr>                                    # Filter by peer address
monitor event peer *                                         # Explicit all peers
monitor event include <type>,<type>                          # Only listed event types
monitor event exclude <type>,<type>                          # All types except listed
monitor event direction received                             # Received events only
monitor event direction sent                                 # Sent events only
monitor event include update peer <addr> direction received  # Combined filters
monitor bgp                                                  # Live peer dashboard (TUI)
Keyword Values Default
peer IP address, peer name, !exclusion, or * * (all peers)
include Comma-separated event types All types (mutually exclusive with exclude)
exclude Comma-separated event types None (mutually exclusive with include)
direction received, sent Both directions

Keywords may appear in any order. include and exclude are mutually exclusive.

Event types span all namespaces: BGP (update, open, notification, keepalive, refresh, state, negotiated, eor, congested, resumed, rpki) and RIB (cache, route). Types are validated at parse time.

Wire method: ze-event:monitor. Supports pipe operators: | json, | table, | match.

Note: monitor bgp is the live peer dashboard (TUI only). monitor event streams live events (SSH exec or TUI). monitor system netlink streams kernel netlink events (SSH exec or TUI, Linux only).

Stream kernel route, link, and address change events as one JSON line per event. Replaces ip monitor on gokrazy appliances.

monitor system netlink                   # All netlink groups (route + link + address)
monitor system netlink route             # Route changes only
monitor system netlink link              # Link state changes only
monitor system netlink address           # Address changes only
monitor system netlink all               # Explicit all (same as no argument)

Wire method: ze-monitor:system-netlink. Linux only; returns "not available on this platform" on other OSes.

System Commands

system help              # Show help (uses dispatcher, includes plugin commands)
system version software  # Show Ze version
system version api       # Show IPC protocol version
system subsystem list    # List available subsystems
system command list      # List all commands (builtin + plugin)
system command list verbose  # List with source (builtin/process name)
system command help "<name>" # Show command details
system command complete "<partial>"  # Complete command names
system command complete "<cmd>" args [<completed>...] "<partial>"  # Arg completion

Process Lifecycle Commands

request shutdown         # Gracefully shutdown
request reboot           # Gracefully shutdown then reboot the system
show status              # Show process status
request reload           # Reload the configuration
show reload-status       # Show how many config reloads have been processed

show reload-status (the reload fence)

Returns the reload generation counter as JSON:

{"generation": 3, "last-outcome": "applied", "last-reload-at": "2026-07-16T09:12:44Z"}
Field Meaning
generation Reloads PROCESSED since daemon start. Starts at 0.
last-outcome applied, failed, or none before the first reload.
last-reload-at RFC3339 UTC completion time; empty string before the first reload.

generation advances on every processed reload, including one that rejected a change or changed nothing. That is what makes it a fence rather than a statistic: a reload that rejects a change (l2tp refusing a listener rebind, for example) leaves no other observable trace, so an observer wanting to assert "the reload ran and correctly left this alone" has nothing else to wait on. It reads generation, triggers the reload, polls until the value advances, then asserts. Waiting on a timer instead makes the assertion pass vacuously against a reload that had not started.

A reload refused with ErrReloadInProgress does not advance the counter: it was queued, not processed, and the replay advances it.

The counter is observational only; nothing reads it to make a decision, so it cannot change what a reload accepts or rejects.

Peer Commands

show bgp peer list                    # List all peers
show bgp peer <selector> detail       # Show specific peer detail
show bgp peer <selector> capabilities # Show specific peer capabilities
show bgp peer <selector> statistics   # Show specific peer statistics
show bgp peer <selector> history      # Show FSM transition history
request peer <selector> teardown [<cease-subcode>]  # Disconnect peer
delete bgp peer <name>             # Remove dynamic peer
request peer <sel> flush           # Wait for forward pool to drain (barrier)

Cache Commands (Ze)

Design history: summary 148 was retired on 2026-08-01 and was not carried into plan/learned/DESIGN-HISTORY.md. That file's header gives the git-recovery route. What survives of the cache pattern is in its "BGP engine: wire encoding and RIB" > Load-bearing invariants: cache Ack and Retain are independent refcount axes, and engine cache ack is cumulative.

request cache forward <id> <sel>    # Forward cached UPDATE to peers
request cache retain <id>           # Prevent eviction
request cache release <id>          # Allow eviction (reset TTL)
request cache expire <id>           # Remove immediately
show cache                          # List cached message IDs

# Batch variants (comma-separated IDs, max 1000):
request cache forward <id1>,<id2>,...,<idN> <sel>  # Batch forward
request cache release <id1>,<id2>,...,<idN>        # Batch release

The cache commands enable route reflection via API: 1. Received UPDATEs are assigned a unique msg-id (per-UPDATE, not per-NLRI) 2. API outputs UPDATE info with msg-id 3. External process decides routing 4. The request cache forward command references msg-id (zero-copy when contexts match) 5. Cache entries expire after configurable TTL (default 60s) unless retained

Fast-path typed SDK (rs-fastpath-3)

The text-RPC request cache forward <id> <sel> path tokenises, parses, and walks the command registry on every call. Plugins that forward many cached UPDATEs per second (route server, future route reflector) use a typed SDK pair instead:

Plugin.ForwardCached(ctx, ids []uint64, destinations []netip.AddrPort) error
Plugin.ReleaseCached(ctx, ids []uint64) error

Both methods round-trip via DirectBridge when the plugin is in-process (zero socket I/O, no tokenisation, no command-registry lookup) and fall back to the ze-plugin-engine:forward-cached / :release-cached methods over the newline-framed plugin RPC connection when the plugin is out-of-process. The engine handler is reactorAPIAdapter.ForwardUpdatesDirect / ReleaseUpdates.

Destinations are peer addresses (netip.AddrPort). Port-0 entries match any peer instance with the same address. Cap: 4096 destinations per call (override via ze.fwd.dest.cap); exceeding the cap is an explicit error, not silent truncation. Empty destination list returns an error too, guarding against an accidental wildcard broadcast — use ReleaseCached to ack without forwarding.

Per-source ordering, egress filter chains, AS-PATH prepend, next-hop policy, replay-on-new-peer, and every other forwarding invariant are preserved. Only the transport differs from the text-RPC path.

Log Commands (Ze)

show log levels                       # Show all subsystem log levels (JSON map)
request log level <logger> <level>    # Change subsystem log level at runtime

Levels: debug, info, warn, err. Changes take effect immediately via slog.LevelVar atomic swap. Only loggers created via slogutil.Logger() or slogutil.LazyLogger() (non-disabled) are shown and modifiable.

Metrics Commands (Ze)

show metrics values       # Dump Prometheus text format output
show metrics list         # List metric names only (no values)
show metrics pool         # Per-attribute pool occupancy and dedup rates

Requires telemetry to be enabled in config (telemetry { prometheus { ... } }). Returns error if metrics registry is not available.

show metrics pool returns 13 per-attribute pools (Origin, AS-Path, LocalPref, MED, NextHop, Communities, LargeCommunities, ExtCommunities, ClusterList, OriginatorID, AtomicAggregate, Aggregator, OtherAttrs) with live/dead slots, bytes, intern count, dedup hit rate.

Peer Selectors

peer *                   # All peers
peer upstream1            # Specific peer by name
peer !upstream1           # All peers EXCEPT this one (for route reflection)

The !<ip> negated selector is useful for route reflection:

# Forward update to all peers except the source
request cache forward 12345 !upstream1

Note: Filter selectors ([local-as ...], [peer-as ...]) from ExaBGP multi-session draft are not supported — the draft never became an RFC.

Route Commands (update text)

All route operations use unified update text syntax with flat attribute declarations (no set keyword) and keyword aliases (short forms accepted, see Keyword Aliases below):

# Announce routes (flat attributes, no 'set')
peer <selector> update text next <ip> [attributes...] nlri <family> add prefix <prefix>...

# Withdraw routes
peer <selector> update text nlri <family> del prefix <prefix>...

# End-of-RIB marker (RFC 4724)
peer <selector> update text nlri <family> eor

# VPLS (L2VPN/VPLS)
peer <selector> update text nlri l2vpn/vpls add rd <rd> ve-id <n> ve-block-offset <n> ve-block-size <n> label-base <n>

# EVPN (L2VPN/EVPN)
peer <selector> update text nlri l2vpn/evpn add mac-ip rd <rd> mac <mac> [ip <ip>] label <n>
peer <selector> update text nlri l2vpn/evpn add ip-prefix rd <rd> prefix <prefix> label <n>
peer <selector> update text nlri l2vpn/evpn add multicast rd <rd> ip <ip>

Route Commands (update cursor)

Stateful cursor mode for efficient replay of stored routes on reconnect. The engine maintains attribute state per (plugin, peer) pair. Subsequent commands send only changed attributes (delta encoding), reducing per-call overhead.

# First command: establish full attribute state + announce NLRIs
peer <selector> update cursor origin igp as-path [65001] med 100 \
  next-hop 10.0.0.1 nlri ipv4/unicast add 10.0.0.0/24 10.0.1.0/24

# Delta: only changed attributes, rest inherited from cursor
peer <selector> update cursor as-path [65001 65003] \
  nlri ipv4/unicast add 10.1.0.0/24

# NLRIs only (all attributes inherited)
peer <selector> update cursor nlri ipv4/unicast add 10.2.0.0/24

# Remove an attribute from cursor
peer <selector> update cursor del med nlri ipv4/unicast add 10.3.0.0/24

# Clear cursor state (call after replay completes)
peer <selector> update cursor done

Cursor mode supports announce-only (nlri <family> add). Withdrawals are not supported because replay re-sends stored routes, never withdrawals.

Removed Commands (use update text instead)

The following legacy commands have been removed:

Old Command Replacement
announce ipv4/unicast <p> next-hop <nh> update text next <nh> nlri ipv4/unicast add prefix <p>
announce ipv6/unicast <p> next-hop <nh> update text next <nh> nlri ipv6/unicast add prefix <p>
announce eor <afi> <safi> update text nlri <family> eor
announce vpls ... update text nlri l2vpn/vpls add ...
announce l2vpn ... update text nlri l2vpn/evpn add ...
withdraw ipv4/unicast <p> update text nlri ipv4/unicast del prefix <p>
withdraw ipv6/unicast <p> update text nlri ipv6/unicast del prefix <p>
withdraw vpls ... update text nlri l2vpn/vpls del ...
withdraw l2vpn ... update text nlri l2vpn/evpn del ...

Watchdog Commands

request bgp watchdog announce <name> [med <N>] [peer]  # Send all routes in pool (optional MED override)
request bgp watchdog withdraw <name> [peer]            # Withdraw all routes in pool from peers

Routes are tagged with a pool when announced:

update text nhop set 10.0.0.1 nlri ipv4/unicast add prefix 1.0.0.0/24 watchdog set mypool

Note: watchdog set in wire-mode updates is parsed but not yet implemented. The request bgp watchdog announce/request bgp watchdog withdraw pool commands work independently of this tagging.

RIB Commands

show bgp rib [filters...] [terminal]        # Unified route display with pipeline
    source filters: received | advertised
    filters: peer <selector>, path <pattern>, prefix <pattern>, community <value>,
             family <afi/safi>
    terminals: count, histogram, graph (AS topology box-drawing)
show bgp rib best [filters...] [terminal]   # Best-path per prefix (RFC 4271 §9.1.2)
show bgp rib status                         # RIB status (peer/route counts)
clear bgp rib in <selector>                  # Clear Adj-RIB-In (* for all peers)
clear bgp rib out <selector> [family]        # Resend Adj-RIB-Out (* for all, optional family)
request bgp rib inject <peer> <family> <prefix> [attrs] # Insert route into Adj-RIB-In (no session needed)
request bgp rib withdraw <peer> <family> <prefix>       # Remove route from Adj-RIB-In
show bgp rib rpf <family> <source-addr>      # RPF lookup (longest-prefix-match in Loc-RIB)

Generic pipes such as match, json, ndjson, table, text, yaml, raw, resolve, origin, log, no-more, display, and fill apply to the answer the command produced. The DAEMON runs them, on every surface. execMiddleware splits the chain off an SSH exec command and applies it. ze cli sends the chain intact and prints what comes back. Only the daemon holds the configuration, so only the daemon can honor environment cli format default.

RIB route filters such as received, advertised, peer, family, prefix, path, and community are command-specific filters registered by the RIB command and folded into the RIB iterator request before route output is generated.

Inject attributes: origin <igp|egp|incomplete>, nhop|nexthop <ip>, aspath <asn,asn,...>, localpref <n>, med <n>. Peer address is a label (valid IP, no session required). Only simple prefix families (IPv4/IPv6 unicast/multicast). IPv4-mapped IPv6 next-hops accepted.

Inter-Plugin RIB Commands (GR/LLGR)

These commands are dispatched between plugins (bgp-gr to bgp-rib) and are not intended for direct user invocation:

request bgp rib retain-routes <peer>                    # Retain routes for peer (GR activation)
request bgp rib release-routes <peer>                   # Release retained routes
request bgp rib mark-stale <peer> <restart-time> [level]  # Mark routes stale (level: 1=GR, 2=LLGR)
request bgp rib purge-stale <peer> [family]             # Purge stale routes (optionally per-family)
request bgp rib attach-community <peer> <family> <hex>  # Attach community to stale routes in family
request bgp rib delete-with-community <peer> <family> <hex>  # Delete routes carrying community in family

Group Commands (Batching)

group start [attributes ...]     # Start batch with shared attributes
peer <selector> update text ...
peer <selector> update text ...
group end                         # End batch, send all

Action-First Syntax (Legacy)

Show Commands

show neighbor [summary|extensive|configuration]
show adj-rib in [<afi> <safi>]
show adj-rib out [<afi> <safi>]

Announce/Withdraw

All route operations now use update text syntax:

update text next <ip> [attributes...] nlri <family> add prefix <prefix>
update text nlri <family> del prefix <prefix>
update text nlri <family> eor

Note: Legacy announce/withdraw commands have been removed. See "Removed Commands" section above for migration table.

Control

teardown <peer-ip> <code> [<reason>]
shutdown
reload
restart
reset
enable-ack
disable-ack
silence-ack
help
version

API Content Configuration (Ze)

Attribute Filtering

ContentConfig.Attributes limits which path attributes are parsed for API output. It is set by the engine, not by a peer's attach block: the content { encoding format attribute } container inside attach process parses and reaches no field of ProcessBinding, so a peer cannot ask for one rendering while another peer asks for a different one.

Available attribute names: | Name | Code | Description | |------|------|-------------| | origin | 1 | ORIGIN | | as-path | 2 | AS_PATH | | next-hop | 3 | NEXT_HOP | | med | 4 | MULTI_EXIT_DISC | | local-pref | 5 | LOCAL_PREF | | atomic-aggregate | 6 | ATOMIC_AGGREGATE | | aggregator | 7 | AGGREGATOR | | community | 8 | COMMUNITIES | | originator-id | 9 | ORIGINATOR_ID | | cluster-list | 10 | CLUSTER_LIST | | extended-community | 16 | EXTENDED_COMMUNITIES | | large-community | 32 | LARGE_COMMUNITIES | | all | - | All attributes (default) |

Benefits of partial parsing: - Reduced CPU (only parse what's needed for routing decision) - Reduced memory (don't store full parsed attributes) - Wire bytes preserved for zero-copy forwarding

NLRI Family Filtering

ContentConfig.NLRI limits which address families are included in API output. Like the attribute filter, it is engine-set: no config leaf reaches it.

Available families: | Config Syntax | Canonical Name | |---------------|----------------| | ipv4/unicast | ipv4/unicast | | ipv6/unicast | ipv6/unicast | | ipv4/multicast | ipv4/multicast | | ipv6/multicast | ipv6/multicast | | ipv4 mpls | ipv4 mpls | | ipv6 mpls | ipv6 mpls | | ipv4/mpls-vpn | ipv4/mpls-vpn | | ipv6/mpls-vpn | ipv6/mpls-vpn | | ipv4/flowspec | ipv4/flowspec | | ipv6/flowspec | ipv6/flowspec | | l2vpn/evpn | l2vpn/evpn | | l2vpn/vpls | l2vpn/vpls |

Special values: all (default), none


Route Attributes

Attributes are flat keyword-value pairs (no set keyword). Both short and long forms accepted. API text output uses short forms; config output uses long forms.

next <ip>                        # Next-hop IP (required) — long: next-hop
origin igp|egp|incomplete        # Origin attribute
path <asn>,<asn>,...             # AS path — long: as-path
pref <int>                       # Local preference — long: local-preference
med <int>                        # Multi-exit discriminator
s-com <comm>,<comm>,...          # Standard communities — long: community
x-com <ext>,<ext>,...            # Extended communities — long: extended-community
l-com <lc>,<lc>,...              # Large communities — long: large-community
originator-id <ip>               # Originator ID
cluster-list <ip>,<ip>,...       # Cluster list
label <label>                    # MPLS label (per-NLRI-section modifier)
rd <rd>                          # Route distinguisher (per-NLRI-section modifier)
info <id>                        # ADD-PATH path ID (per-NLRI-section modifier) — long: path-information
atomic-aggregate                 # Atomic aggregate flag
aggregator <asn> <ip>            # Aggregator
aigp <value>                     # AIGP
split /<len>                     # Ze: prefix expansion (see below)

Keyword Aliases

Long (config) Short (API) Also accepts
next-hop next nhop (legacy)
local-preference pref
as-path path
community s-com short-community
large-community l-com
extended-community x-com e-com
path-information info
route-distinguisher rd

Lists use commas (no spaces): path 65001,65002. Brackets accepted for transition: as-path [65001 65002].


Split Keyword (Ze Extension)

The split keyword expands a prefix into smaller prefixes. All attributes apply to each generated prefix.

Syntax

split /<target-length>

Example

# Announce 2 prefixes with one command
update text next 1.2.3.4 nlri ipv4/unicast add prefix 10.0.0.0/23 split /24
# → 10.0.0.0/24 next-hop 1.2.3.4
# → 10.0.1.0/24 next-hop 1.2.3.4

# With MPLS label - label applies to each prefix
update text next 1.2.3.4 nlri ipv4/nlri-mpls label 100 add prefix 10.0.0.0/22 split /24
# → 10.0.0.0/24 label 100
# → 10.0.1.0/24 label 100
# → 10.0.2.0/24 label 100
# → 10.0.3.0/24 label 100

# With L3VPN - RD and label apply to each prefix
update text next 1.2.3.4 nlri ipv4/mpls-vpn rd 100:1 label 200 add prefix 10.0.0.0/23 split /24
# → 10.0.0.0/24 rd 100:1 label 200
# → 10.0.1.0/24 rd 100:1 label 200

Supported Families

Family Split Support Notes
IPv4/IPv6 unicast Standard prefix expansion
IPv4/IPv6 nlri-mpls Label copied to each prefix
IPv4/IPv6 mpls-vpn RD + label copied to each prefix
FlowSpec N/A - uses match rules, not prefixes
VPLS/EVPN Different NLRI structure

Constraints


FlowSpec Commands

FlowSpec rules use the unified update text syntax. The match components are the FlowSpec NLRI (nlri ipv4/flow add <components>); the action is carried as a FlowSpec extended community declared with the extended-community attribute before the nlri section (discard is sugar for traffic-rate 0).

# Match TCP traffic to 10.0.0.0/8 port 80 and drop it
update text extended-community discard \
  nlri ipv4/flow add \
  destination 10.0.0.0/8 \
  protocol tcp \
  destination-port =80

# Withdraw the same FlowSpec rule (match components identify it)
update text nlri ipv4/flow del \
  destination 10.0.0.0/8 \
  protocol tcp \
  destination-port =80

Match Components

Keyword Description
destination Destination prefix
source Source prefix
destination-port Destination port
source-port Source port
port Any port
protocol IP protocol
next-header IPv6 next header
tcp-flags TCP flags
icmp-type ICMP type
icmp-code ICMP code
fragment Fragment flags
dscp DSCP value
packet-length Packet length
flow-label IPv6 flow label

Actions (then)

Keyword Description
accept Accept traffic
discard Drop traffic
rate-limit Rate limit
redirect Redirect to VRF
redirect-next-hop Redirect to next-hop
mark Set DSCP
community [...] Add community

Filter Callbacks

The engine sends filter-update callbacks to external plugin filters during UPDATE processing. This is a callback RPC (engine to plugin), not a user command.

Field Type Description
filter string Filter name (declared at stage 1, dispatches to the right handler)
direction string import or export
peer string Peer IP address
peer-as uint32 Peer ASN
update string Text-format attributes and NLRI (only declared attributes)

Response: {"action":"accept"}, {"action":"reject"}, or {"action":"modify","update":"<delta>"} with only changed fields.


Response Format

Success (with serial prefix)

{"type":"response","response":{"serial":"1","status":"done"}}

Error

{"type":"response","response":{"serial":"1","status":"error","data":"description"}}

Rejected rows: errors and data

A handler can answer with a row generator rather than a built payload. It can reject a row while the walk continues. A consumer that reads the whole answer as one string sees the rejected rows under a SIBLING key. A partial result therefore renders, instead of collapsing into one error string.

Answer Rendering
rows, no rejected row {"peers":[...]}, or a bare [...] when the handler names no envelope. Unchanged
rows and rejected rows {"peers":[...],"errors":[...]}
rejected rows, no envelope key {"data":[...],"errors":[...]}
the handler names its envelope errors refused, on this path and on the record path

errors appears only when a row was rejected. An ordinary answer keeps the shape it had, and no consumer meets a key it has not seen. data is where the rows go when the handler names no envelope and a row was rejected. A bare array has nowhere to carry a sibling.

| count counts the rows the command produced and never the rejected ones, so the two collections stay separately countable. A commit that applied 97 leaves and rejected 3 renders both, rather than the 97 being lost with the error.

Two kinds of handler answer with a generator. system command list is the engine's, and a plugin command handler is the SDK's. Every other command returns a built payload, and none of these keys appears for it.

A plugin command answers with records

execute-command is the callback the engine sends for a command a plugin registered. Every plugin answers it with a head, its records and a terminator, and the engine reads that sequence for every plugin.

The handler decides what it produces, and the wire decides how it travels.

The handler returns On the wire What routeToProcess builds
a built value the doc item type and one record carrying that value plugin.RawJSON, unchanged
a plugin.Records walk of 256 rows or fewer the doc item type and one record carrying the collapsed document plugin.RawJSON over that document
a plugin.Records walk of more than 256 rows, declaring no columns the map item type and one record for each row plugin.Records over the arriving rows
a plugin.Records walk of more than 256 rows, declaring its columns the tab item type, the names on the head, and one positional record for each row plugin.Records over the arriving rows, carrying the head's names

The dispatcher branches on the head's item type and never on what the handler returned. A bounded walk is therefore the document it has always been, and only a walk that streams reaches an operator as records.

A tab answer reaches the operator as the same objects a map answer would have carried. The engine forwards the head's column names beside the rows, and the rendering zips each positional row against them. A command that declares a schema and one that does not therefore answer one document for the same data.

The value a built payload carries is unchanged, byte for byte. Only the frame around it changed, and it changed for every peer. Nothing declares an answer shape, so there is one frame and every reader knows it before the first line.

The rows are pulled as the operator's rendering writes them, so the engine never holds the whole collection for a walk that streams. A row wider than one wire message is rejected and the walk continues. That row reaches the operator under errors, beside the rows that were applied.

Show Neighbor

{
  "neighbor": {
    "address": "192.168.1.2",
    "local-address": "192.168.1.1",
    "local-as": 65001,
    "peer-as": 65002,
    "router-id": "1.1.1.1",
    "state": "established"
  }
}

Show Adj-RIB

{
  "routes": [
    {
      "nlri": "10.0.0.0/8",
      "next-hop": "192.168.1.2",
      "origin": "igp",
      "as-path": [65002]
    }
  ]
}

Command Dispatch

Command Tree Structure

Note: This tree shows the internal noun-first dispatch structure, not the user-facing grammar. User-facing commands are verb-first (show/request/clear/update/monitor roots, e.g. show bgp peer <sel> detail, request cache forward, clear bgp rib in); the noun-first RPCs below remain only for internal dispatch. Nodes such as peer/<selector>/announce and peer/<selector>/withdraw reflect removed verbs (see "Removed Commands").

daemon
├── shutdown
├── reload
├── restart
└── status

plugin
└── session
    ├── ready
    ├── ping
    └── bye

bgp
├── help
├── command
│   ├── list
│   ├── help
│   └── complete
├── event
│   └── list
├── log
│   ├── levels            # Show subsystem log levels
│   └── set               # Set subsystem log level at runtime
├── metrics
│   ├── values            # Show Prometheus metrics (text format)
│   ├── list              # List metric names
│   └── pool              # Per-attribute pool occupancy and dedup rates
└── plugin
    ├── encoding
    ├── format
    └── ack

peer
├── list
├── detail
├── capabilities
├── statistics
└── <selector>
    ├── detail
    ├── capabilities
    ├── statistics
    ├── teardown
    ├── announce
    ├── withdraw
    └── group

rib
├── routes [sent|received|sent-received] [filters...] [count|json]
├── best [filters...] [count|json]
├── status
├── clear [in|out]
├── inject <peer> <family> <prefix> [attrs...]
└── withdraw <peer> <family> <prefix>

group
├── start
└── end

monitor
├── bgp                   # Live peer dashboard (TUI)
└── event                 # Stream live events (keeps session open)

Ze Implementation Notes

Command Dispatcher

type Handler func(ctx *Context, peers []string, remaining string) error

type DispatchTree map[string]interface{}  // Handler or nested DispatchTree

func Dispatch(tree DispatchTree, tokens *Tokenizer, reactor *Reactor) (Handler, []string) {
    // Walk tree consuming tokens
    // Return handler and matched peers
}

YANG-Typed Command Arguments

Operational commands declare their argument types as YANG leaves inside ze:command containers. The same leaf metadata drives three consumers:

  1. Completer (command/completer.go): enum values become tab-completion suggestions; keyword leaf names appear as completable tokens.
  2. Dispatcher (plugin/server/command.go): validates args against ArgDefs between tokenize and handler call (two-phase: keyword extraction, then positional matching).
  3. Documentation: leaf descriptions provide help text.
type ArgDef struct {
    Name       string         // YANG leaf name (kebab-case)
    Kind       ArgKind        // ArgString, ArgEnum, ArgUint, ArgUnion
    EnumValues []string       // Valid enum values
    UintBits   int            // 8, 16, 32, or 64
    Ranges     []UintRange    // Valid ranges (disjoint segments supported)
    Pattern    *regexp.Regexp // Compiled XSD pattern for ArgString
    UnionDefs  []ArgDef       // Member types for ArgUnion
    Mandatory  bool           // True if YANG leaf has mandatory true
}

ArgDefs are extracted from YANG by BuildCommandTree (config/yang/command.go) and stored on command.Node.ArgDefs. The dispatcher receives them via RegisterOptions.ArgDefs populated by PathToArgDefs.

Runtime-dynamic hints (e.g., address families from plugin registry) remain as ValueHints callbacks. Static hints (log levels, FD limit "max") are YANG-declared and served through ArgDefs.

Plugin Command Completion

Plugin-registered commands (from the plugin CommandRegistry, not YANG) are absent from the YANG-derived command tree, so they are injected into the interactive completion tree after the daemon starts. CommandRegistry.VisibleCommandEntries() returns every non-Hidden command as a command.CommandEntry{Name, Description}; command.MergeCommandPaths inserts each path into the tree as completion-only nodes. The merge is non-destructive: an existing YANG node keeps its WireMethod and description, so a plugin command never shadows a builtin at the completion layer (mirroring dispatch precedence).

Hidden commands still dispatch when typed in full. They never appear in completion, in help, in the MCP tools/list result, or in the API command list. buildCommandMeta is the one producer of the last two surfaces, and it skips every hidden plugin command.

Quiesce Barrier (test synchronization)

request quiesce (ze-system:quiesce) blocks until every registered subsystem has drained its pending asynchronous work, then replies — a barrier tests use in place of a fixed time.sleep. It is the general form of ze-bgp:peer-flush: the control plane is already synchronous (a command reply lands after its handler runs), but downstream effects (routes flushed to peer sockets, and later FIB/tc/listeners) complete after the reply, so a test does send(change); request quiesce; assert on-wire with no sleep.

Subsystems register a Quiescer at runtime (they need a live reference such as the reactor), and the handler discovers them through the registry, with no per-subsystem switch. The BGP reactor auto-registers TWO quiescers when it attaches to the server (registerReactorQuiescer): bgp-forward-pool (the reactor's FlushForwardPool, draining post-establishment forwarded routes) and bgp-peer-sync (DrainPeerSync, draining each peer's initial-sync opQueue, which goes DIRECT to the session and bypasses the forward pool). Each drain is bounded by a per-subsystem timeout, so a wedged subsystem yields an error naming it instead of hanging the daemon.

Invocation note: request quiesce and request peer <sel> flush are api-yang RPCs reached through dispatch-command, not as direct wire methods. A plugin calls api.dispatch("request quiesce") (the test SDK's ze_api.quiesce() and ze_api.wait_for_ack() both do this); a raw _call_engine("ze-system:quiesce") returns "unknown method" because dispatchPluginRPC routes only ze-plugin-engine:* engine ops plus codec RPCs.

Extension point: Layer 2/3 subsystems (a kernel-FIB quiescer, a tc/qdisc quiescer) register into the same registry and request quiesce drains them with no change to the barrier. wait_for_ack is now a thin, sleepless wrapper over this barrier: the two BGP quiescers together cover the forward pool AND the per-peer initial-sync drain, so a route sent during establishment is on the wire (past its EOR) before the barrier returns.

Peer Selector Parsing

type Selector struct {
    All       bool
    IP        netip.Addr
    Filters   map[string]string  // local-as, peer-as, local-ip, id, family
}

func ParseSelector(s string) (*Selector, error) {
    if s == "*" {
        return &Selector{All: true}, nil
    }
    if strings.HasPrefix(s, "[") {
        return parseFilteredSelector(s)
    }
    ip, err := netip.ParseAddr(s)
    return &Selector{IP: ip}, err
}

Command Registry

var Commands = []CommandInfo{
    {"daemon shutdown", false, nil},
    {"peer * update text", true, []string{"next", "origin", ...}},
    // ...
}

Per-command declarations: pipe filters and column order

Two registries let a command say something about itself to the CLI. Both are keyed by command path. Both resolve a command to the declaration registered on the longest path that is a prefix of it. So show bgp rib best picks up a declaration on show bgp rib unless it registers one of its own. A command that must NOT inherit registers an EMPTY declaration: absent and empty are different answers, and this is the trap both registries exist in.

Registry Declares Read by
RegisterPipeFilters the pipe segments this command accepts as its own, which foldFilters rewrites into server-side arguments foldFilters, the completer, the pipe validator
RegisterColumns the order the table and text renderers put this command's columns in tableStyle.orderKeys, through the four ProcessPipes* wrappers

One implementation resolves both: commandRegistry[T] in column_order.go.

A column order never enters the payload. It is captured when the formatter is built, from the command string the wrapper already holds, and it reaches | table and | text only. | json, | ndjson and | yaml keep their alphabetical keys, because a program reads those three and key order carries no meaning for a program.

A command declares one order per record shape. show bgp renders an outer record and a list of peer rows. Both carry an uptime key in a different position. So the renderer applies the declaration that names the most of the keys in the record it has in hand.

| display and | fill: the operator's own answer

An operator overrides both halves of that with two generic pipe operators. | display <field>... names the fields the answer leads with. | fill [alpha] [reverse] says whether the fields it did not name come back at all, and in what sequence. Each takes ONE type of argument, so no token is a field name in one position and a keyword in another.

| fill on its own orders the remaining fields by the command's own declaration. alpha orders them by field name instead. reverse flips whichever way is in force. Neither way measures a column: each decides the sequence from the key set and a declaration.

A third way was removed on 2026-08-19. | fill overall ordered columns by the width they render at, and that width is known only after every cell of the whole answer has been rendered. It made the first row unwritable until the last row had been read, which is the one thing a streamed answer cannot do. overall is now refused by name, and | fill itself is untouched.

The two halves of the request travel by different routes, and the split is what makes them work under every format:

Half Where it is applied Reaches
Selection: which fields applyDisplaySelect, over the payload, at the operator's position in the chain every format, \| json, \| ndjson, \| yaml and \| raw included
Sequence: in what order columnRequest carried on tableStyle \| table and \| text only

Selection is a data question the operator asked out loud, so a program gets the answer. Sequence is presentation, so it stops at the two renderers.

selectFields walks the same shapes tableStyle.renderValue walks, and applies the same rule orderKeys applies. A record that carries at least one displayed field is cut to the displayed ones. A record that carries none is left whole. Without that agreement a nested sub-table and the JSON behind it would answer with different fields. Without it a record naming nothing displayed would render as a box with no rows.

A kind the foldFilters switch does not name stays in the chain. The switch names the five kinds a command can own as a filter it resolves itself. Its default: arm carries every other kind to the chain ApplyPipes runs over the answer. Both sides run in the daemon. That arm is load-bearing. Without it a kind named nowhere reached neither side, for every command that registers filters of its own, and nothing reported the loss. TestColumnOpsSurviveFoldFiltersOnFilteredCommand, TestAliasSurvivesFoldFiltersOnFilteredCommand and test/ui/display-fill-filtered-command.ci are what hold that.

Pipe aliases: a name for an operator chain

An alias is a name an operator types in the operator slot, standing for a chain they would otherwise retype. show bgp | peers says what show bgp | display peers says.

Two callers declare one, and both write the same registry.

Registration Table Resolved
RegisterAliases([]string{"show bgp"}, ...), from Go compiled into the daemon aliasRegistry, the same commandRegistry[T] the two registries above use by the longest command path that is a prefix of the command
RegisterAliases(nil, ...), from the same Go globalAliases, a table of its own for every command, when the per-command lookup carries no alias of that name
RegisterPluginAliases(owner, commands, declared), from a plugin's Stage 1 message aliasRegistry, on the command paths that plugin declared the same longest-prefix rule. A plugin reaches no global table

The global table is separate rather than a registration on the empty command path. commandRegistry.register skips an empty path, and commandMatchesPrefix refuses an empty prefix against every non-empty command, so such a registration would match nothing and report nothing.

expandAliases runs between ParsePipe and foldFilters, so classification only ever sees operators the parser already knows. It is ONE pass, and four properties are what make one pass enough. checkAlias is the one reading of all four, so the two callers can never disagree about which declarations are sound:

The two callers differ in the ANSWER, not in the checks. checkedAlias turns a refusal into panic("BUG:"). Only Go in this repository reaches RegisterAliases, so a bad registration there is a Ze defect the compiler cannot see. RegisterPluginAliases returns the refusal as an error, because the strings it reads arrived over a socket.

An alias never enters the payload. expandAliases replaces the name with the operators before the chain runs, so a command handler cannot tell an alias from the chain it stands for. The chain is expanded in the process that PARSES it, and for a plugin's alias that process MUST be the daemon. Read "Where an alias resolves, and where it does not" below.

show bgp registers the two in-tree aliases that exist. Both are a selection among sibling keys, because that answer carries its aggregates and its peers array at the same level:

Alias Expands to
summary display router-id local-as uptime peers-configured peers-established
peers display peers

The BGP RPKI plugin declares a third, summary on show bgp rpki, over the Stage 1 channel the next section describes.

A plugin declares a pipe alias in its Stage 1 message

DeclareRegistrationInput.Pipes is a list of PipeDecl, beside the lists that carry families, commands, filters, doctor checks and enrichers. The SDK re-exports the type, so an external author imports one package. Each entry carries four strings, and the engine parses the expansion once, at registration.

Field Meaning
command The command path the alias sits on. It MUST be one of the commands this plugin declares in the same message
name The word an operator types after the pipe character. Lowercase kebab-case, one word
description The line completion and command help show beside the name
expansion The operator chain the name stands for, written the way an operator would type it

validatePipeDecls reads the shape and the ownership, in the position where Stage 1 already validates doctor checks and enrichers, before it converts anything. registerPluginPipes then writes the accepted set under startupRegistrationMu, between the registry row and the runtime families. Each later failure unwinds what the steps above it wrote.

A plugin names only a path it declared itself, and it reaches no global table. A path another PLUGIN declared is refused a step earlier: PluginRegistry.Register runs before the alias write and rejects a command name another plugin holds, so the second plugin fails on the COMMAND and never reaches its alias.

What the check confirms is that the plugin DECLARED the path, not that the daemon routes that path to it. A plugin that declares a name the daemon serves itself, show bgp for one, passes here. The dispatcher's own registry rejects that command entry later as a builtin conflict, and the plugin keeps running, so its alias sits on a command path the daemon answers. It can only ADD a name there, never take one: the exact-path check refuses a name the path already carries, mergedAliases keeps what the path held, and the name leaves with the plugin. Declaring a name a builtin already serves is a plugin defect, and the daemon logs it as command registration rejected ... conflicts with builtin.

Collision has two populations, because there are two resolution rules

Reading collision as "any overlapping path" refuses the case the channel exists for. show bgp carries an alias named summary, and every show bgp * path overlaps show bgp, so that reading refuses show bgp rpki | summary before it is written.

Pair Collides when Why
Alias against alias the two sit on the SAME normalized command path lookupAlias reads the set on the longest registered prefix and never falls back to a shorter one. A longer path SHADOWS a shorter one, and that is how show bgp rpki answers summary while show bgp answers one of its own
Alias against pipe filter their command paths OVERLAP foldFilters resolves a command's own filter for the whole subtree the filter covers, so an overlapping filter makes the alias unreachable
Alias against a built-in operator name always ParsePipe reads the built-in name first, so the alias is never reached

aliasOnPath is the exact-path reading and filterShadowing is the overlapping one. A declaration is also refused when one name appears twice on one path in one message. A declaration naming a command path the plugin did not declare is refused too.

A refusal fails the whole Stage 1 registration, and the plugin does not start. Nothing is registered when any one declaration is refused, so a plugin never has to undo a partial registration. The message names the plugin, the command path and the alias name. The daemon log is where an operator reads it, because the engine stops a refused plugin before it can report anything itself.

A declaration ADDS to a path. It never replaces what the path holds

commandRegistry.register stores one value for each path, so writing a declared set straight into the registry drops every alias that path already answered to. show bgp rpki already carries the empty declaration the in-tree BGP command plugin puts on every child of show bgp. A plugin therefore declares onto an occupied path in the ordinary case. mergedAliases adds the declared names to what the path holds. Every declared name is checked against that same set first, so nothing merged this way replaces anything.

Removal is by ENTRY for the same reason. UnregisterPluginAliases takes back the names one owner registered, and leaves the in-tree names and other owners' names in place. A path the owner created from nothing goes once its last entry is gone. Without removal a plugin that stops cannot start again, because the exact-path check then refuses it its own name.

Two call sites remove it, and they are the two that remove the registry row and the runtime families. rollbackStartupProcess is both the failed-startup path and the config-reload stop path. The other is the family-conflict unwind inside onRegistration.

The engine derives the barrier that stops an alias below its command

An alias on show bgp rpki is inherited by show bgp rpki roa, because roa registers nothing of its own and the lookup resolves the longest registered prefix. That offers the name on a leaf whose answer cannot carry it.

aliasBarriers derives the answer from the plugin's own command list. It reads every command the plugin declared that sits strictly below a path carrying one of its aliases. Each such command that declares no alias itself gets an empty declaration. The in-tree form of the same barrier is written by hand in peer.go, over cmdBgpChildren. A plugin author writes nothing and does not have to know the resolution rule.

The pipe layer selects and re-sequences. It cannot compute

This is the obligation every command that wants an alias owes, and it is a property of the PAYLOAD rather than of the pipe layer.

display keeps the named keys and drops the rest. fill re-sequences what display did not name. count replaces the answer with a number. first and last cut the item list. match keeps the rendered lines that match a pattern. The format operators render one payload a different way.

None of them renames a key, adds two numbers, counts the rows whose field holds a given value, or asks the handler for anything. So a command whose second view is a pipe alias MUST EMIT the aggregate fields beside the detail rows, as siblings at one level. show bgp has always done this, and show bgp rpki now does it too: overviewCommand writes appendSummaryFields and appendCacheServers into one record, and | summary selects the first half.

Four of the seven RPKI aggregate fields are computed. vrp-count sums the two family counts, sessions-established counts the sessions in one state, sessions-total renames what show bgp rpki status calls sessions, and validation-enabled is a constant. The command computes them, and the alias only selects them.

The expansion is a second copy of the field names, and display names keys and reports no miss. A field added to the payload and not to the expansion is therefore dropped from the alias in silence. A conversion owes three things:

RPKI does this with summaryFieldNames and buildSummaryAliasExpansion.

Two questions send a candidate back to being a subcommand. Would the operator have to supply a value? An alias takes no argument. Does the answer need data the parent's payload does not carry? An alias reshapes what was returned.

show bgp rpki roa 192.0.2.0/24 fails both and stays a subcommand. show bgp rpki cache fails the second one: it reports preference, session-id, serial and three intervals that the bare answer does not carry.

Where an alias resolves, and where it does not

The chain is resolved in the process that parses it, and a plugin's alias lives in the daemon's registry alone.

Surface Parses the chain A plugin's alias works
ze cli -c "<command>", and any SSH exec channel the daemon, in execMiddleware
The TUI a plain ssh client with a pty reaches the daemon, which hosts the Bubble Tea model
ze cli with no command argument the CLIENT process, in executeOperationalCommand
cliClient.StreamMonitor, for a streaming monitor command the CLIENT process

On the two client-side rows the operator reads pipe error: unknown pipe operator: <name>, and Tab offers the name nowhere. The aliases compiled into the client resolve there, which is what hid the gap. Tab after the pipe character on show bgp offers summary and peers in the same client. The repair is a wire surface that carries the daemon's alias table to the client at session start, and it is NOT built.

Discovery: the running daemon is the only source

A surface that reads the compiled tree in its own process starts no plugin, so it never sees a plugin's Stage 1 message.

Surface Reads Reports a plugin's alias
command help "<name>" the running daemon's registries
Tab completion in the daemon-hosted TUI the running daemon's registries
make ze-command-list the compiled tree in its own process
ze help command --json, and the wiki catalog built from it the compiled tree in its own process

command help lists an in-tree alias and a declared one the same way, and it listed neither before 2026-08. It reports the expansion beside the description. An alias takes no argument and names no other alias, so the chain it stands for is the whole of what the name does.

A plugin cannot declare a column order

completeDisplayFields reads the column registry, and only Go compiled into the daemon writes it. So | display <partial> over a plugin command offers no field names, and | table sorts a plugin answer's keys alphabetically. The alias itself works and its NAME completes, because that name comes from the alias registry. show bgp rpki, show bgp rpki summary and show bgp rpki | summary therefore render one identical record in three different orders.

The chain over a row generator

A handler that answers with a row generator runs the same chain, one record at a time. ApplyPipesRecords is the record half of ApplyPipes. | match, | count, | first, | last, | display, | resolve and | origin each act per record, so | count holds nothing and | last 8 holds eight records.

A format operator changes no record. RenderRecords renders what the chain produced. It writes per record for one chain alone: | ndjson, over an answer that declares no column schema, whose chain folded no display metadata. Every other format needs a document. A column width needs every row, and metadata rides in the envelope.

| table and | text therefore collect. That cost is paid once, in the renderer, and the record stage forwards the records untouched.

A chain that answers a document of its own is not filed under the command's envelope. | count is the one operator that does: it answers {"count":N} whatever it counted, and the whole-payload path replaces the payload for the same reason. system command list | count therefore answers {"count":N}, the same document every other command's | count answers, and not {"commands":[{"count":N}]}.

Authorization is decided once, at dispatch, and the rows are produced after that decision. That is what a built payload has always done, and a generator changes only how long the gap is. There is no per-row authorization, so a handler MUST NOT yield a row the caller was not already authorized to receive when the command was accepted.

A chain the validator refuses answers one rejected row and pulls nothing, so an unreadable chain never reads as an empty answer.


Managed Config RPCs

RPCs for hub-client managed configuration. These operate over MuxConn after auth, separate from the plugin 5-stage protocol.

Verb Direction Payload Response
config-fetch Client to hub {"version":"<hash-or-empty>"} {"version":"<hash>","config":"<base64>"} or {"status":"current"}
config-changed Hub to client {"version":"<hash>"} {}
config-ack Client to hub {"version":"<hash>","ok":true} or {"version":"<hash>","ok":false,"error":"..."} {}
ping Either direction {} {}

Version hash is truncated SHA-256 (16 hex characters) of config bytes.


MCP Methods

The MCP Streamable HTTP transport (revision 2026-07-28) is stateless and strictly client-to-server. Every message is its own HTTP POST, and the server never sends an independent JSON-RPC request on any stream. These methods are distinct from ze's own dispatcher commands (above). They are part of the MCP protocol contract, and each request carries the version and capabilities it speaks in its own params._meta.

Method Direction Purpose
server/discover Client -> server Advertise supportedVersions, capabilities (including for MCP Apps and for background tasks), and instructions. Mandatory for a server to implement, and optional for a client to call. extensions["io.modelcontextprotocol/ui"]extensions["io.modelcontextprotocol/tasks"]
tools/list Client -> server The tool inventory, derived from the command registry at call time, in a deterministic order. A descriptor carries _meta.ui only when the request declared the. io.modelcontextprotocol/ui
tools/call Client -> server Run a tool. Answers resultType: "task" when the command's ze:task-support annotation is required and the request declared the tasks extension. Answers resultType: "input_required" when the tool needs a value the call did not supply

There are no server-initiated methods. notifications/tasks/status existed under the earlier revision and was removed with the session and the GET stream it required.

elicitation/create survives, but no longer as a method. elicitation/create is now a value inside the inputRequests map of an InputRequiredResult. A server RETURNS that result from tools/call (or resources/read), and the server does not send it. The client then retries the original request with inputResponses. See MCP Elicitation for the full round trip.

See MCP Architecture Overview for how capabilities are declared per request.

MCP Task Methods

These are the io.modelcontextprotocol/tasks extension, not core protocol. A tools/call on a command annotated ze:task-support required creates a background worker and returns a CreateTaskResult immediately. The client then polls for status, and the server pushes nothing.

Method Direction Purpose
tasks/get Client -> server Current state of a task by taskId. A terminal task carries its outcome here: result when completed, error when failed
tasks/update Client -> server Answer a task's outstanding input requests. Ze raises none, so it verifies ownership and acknowledges with an empty result, ignoring unknown inputResponses keys
tasks/cancel Client -> server Request cancellation of a working task

tasks/list and tasks/result were REMOVED this revision and are now unknown methods, answered HTTP 404 with -32601. A poll of tasks/get replaced the blocking tasks/result. That change is why a terminal state carries its payload. tasks/list was dropped outright. No method enumerates tasks now, so a client tracks the ids it was given.

All three surviving methods require the request's _meta["io.modelcontextprotocol/clientCapabilities"] to declare io.modelcontextprotocol/tasks under extensions. The bare tasks member that the earlier revision used is no longer accepted. A request without the declaration is refused with -32021 (MissingRequiredClientCapability) and HTTP 400, carrying data.requiredCapabilities in the extension shape.

Task creation is server-directed. There is no task member on tools/call params. The server decides per tool from the YANG ze:task-support annotation: required always, forbidden never, and optional synchronous. A client that did not declare the extension gets the ordinary synchronous result, not an error.

MCP Resource Methods

Method Direction Description
resources/list Client -> server List all available UI resources. The response comes from an embedded FS walk
resources/read Client -> server Read a single resource by URI. Returns content as text or base64 blob. ui://

Both results carry cache hints (see MCP Result and Error Envelope below).

Neither method is gated on a client capability. resources is a member of ServerCapabilities, not of ClientCapabilities (whose members are experimental, roots, sampling, elicitation and extensions). No conformant client can therefore declare resources. A server that advertises the capability in server/discover serves it.

MCP Result and Error Envelope

Every successful MCP result carries a resultType and _meta["io.modelcontextprotocol/serverInfo"], stamped from one shared helper so no method can omit them. resultType is complete for a finished result. resultType is input_required for the MRTR interim result a handler produces when it needs a value the request did not supply. The shared helper preserves input_required and does not overwrite it. And a guard on the single path out of dispatch refuses to emit input_required on any method other than prompts/get, resources/read and tools/call.

Four methods additionally carry the CacheableResult fields, ttlMs and cacheScope. Both are non-optional on those results.

Method ttlMs cacheScope
server/discover 60000 private
tools/list 60000 private
resources/list 3600000 private
resources/read 3600000 private

tools/call and every tasks/* method carry neither field, in either result shape. Three reasons make that correct. tools/call is absent from the specification's cacheable-operation list. Interim input_required results are explicitly not cacheable. And a result produced by an MRTR retry must not be cached at all.

Ze therefore applies the hints from a per-method table on the way out of dispatch. The shared ok() responder does not apply them, because tools/call also uses it.

cacheScope is private on every cacheable result, which forbids a shared gateway or caching proxy from serving one authorization context's response to another. It is defence in depth, not the access control: per-request authentication remains the gate.

Code HTTP Meaning
-32020 400 HeaderMismatch: a required standard header is missing or disagrees with the body
-32021 400 MissingRequiredClientCapability: data.requiredCapabilities names what the client must declare
-32022 400 UnsupportedProtocolVersion: data.supported lists the server's versions, data.requested echoes the client's
-32602 400 for a malformed params._meta, 200 otherwise Invalid params
-32601 404 Unknown method, including initialize
405 GET or DELETE to the MCP endpoint

Three HTTP request headers are mandatory on every POST, and each one must agree with the body:

Ze decodes the =?base64?...?= sentinel in Mcp-Name first, then compares the decoded value with the body.

Tool descriptors in tools/list carry _meta.ui.resourceUri when the command group has a ze:ui-resource YANG extension. The _meta.ui block is emitted unconditionally, and the ui:// asset it points at is readable by every caller.


Last Updated: 2026-07-29