Adding Commands
Plugins can expose commands for runtime interaction via the ze API.
Declaring Commands
Commands are declared in the Registration struct passed to Run. The engine learns about them during Stage 1 (declare-registration).
err := p.Run(ctx, sdk.Registration{
Commands: []sdk.CommandDecl{
{Name: "my-plugin status", Description: "Show current status"},
{Name: "my-plugin check", Description: "Trigger immediate check", Args: []string{"target"}},
},
})
Handling Commands
Register a handler with OnExecuteCommand before calling Run. The handler receives the command serial, command name, arguments, and peer selector.
p.OnExecuteCommand(func(serial, command string, args []string, peer string) (status string, data any, err error) {
switch command {
case "my-plugin status":
return "done", map[string]any{
"status": "running",
"uptime": 3600,
}, nil
case "my-plugin check":
if len(args) < 1 {
return "error", map[string]string{
"error": "usage: my-plugin check <target>",
}, nil
}
result := performCheck(args[0])
return "done", result, nil
default:
return "error", map[string]string{
"error": "unknown command: " + command,
}, nil
}
})
Wire Format
Commands are delivered to the plugin as execute-command RPCs over the MuxConn connection. The wire format uses #<id> <verb> [<json>] framing.
Request (engine to plugin)
#17 ze-plugin-callback:execute-command {"serial":"abc123","command":"my-plugin status","args":[],"peer":""}
Success Response (plugin to engine)
The answer is a head, its records and a terminator, on every connection. The frame is the same whatever the payload is, so a handler that built one value takes the same three lines as a handler that walked a table.
An answer line carries no verb and no key name. The field after the id is a three-byte word saying what the line IS:
| Word | The line is |
|---|---|
top |
the head, which opens the answer |
row |
one record your command produced |
bad |
one record it rejected. The walk goes on |
end |
the terminator, which ends the answer |
nay |
the whole answer to a command text naming no command |
The head carries a second three-byte word saying how the records read:
| Word | The records are |
|---|---|
doc |
one document. The whole answer is that one value |
map |
one map of names to values for each record |
tab |
one positional row for each record, read against the column names |
Every field after those is positional and takes one of two shapes. A NUMBER is
decimal digits closed by a space or by the end of the line. A TEXT is decimal
digits, a colon, then that many BYTES. The count is a BYTE count, never a count
of characters. A text of zero bytes is written 0:, present and empty, so a
line's field count never varies.
#17 top doc 0: 0:
| | | | |
| | | | +----- column names, 0 BYTES, so the records are not positional
| | | +-------- envelope name, 0 BYTES, so the document carries its own
| | +------------ item type doc: the whole answer is one document
| +---------------- kind top: the head, always the first line
+-------------------- correlation id 17, echoed from the request
#17 row 34:{"status":"running","uptime":3600}
| | | |
| | | +----- those 34 bytes, what your handler returned, byte for byte
| | +-------- 34, the payload's BYTE count, then the colon every text carries
| +------------ kind row: one record the command produced
+---------------- correlation id 17
#17 end 1 0 0:
| | | | |
| | | | +----- message, 0 BYTES, so the command stated none
| | | +------- 0 rows rejected
| | +--------- 1 record produced
| +------------- kind end: the terminator, always the last line
+----------------- correlation id 17
The engine reads that sequence back into one ExecuteCommandOutput: the record
is its data, and its status is derived from the terminator, which is the one
line an answer states an outcome on.
Error Response (plugin to engine)
#17 error {"message":"execute-command not supported"}
Return Values
The OnExecuteCommand handler returns three values: (status string, data any, err error).
Success with Data
return "done", map[string]any{
"count": 42,
"items": []string{"a", "b"},
}, nil
The SDK marshals this value once and sends it as the one record of the answer.
The three lines read exactly as the decoded ones above. The doc head names no
envelope and no columns, the row states 30 bytes and then the marshaled value,
and the terminator counts one record, no rejection and no message.
#17 top doc 0: 0:
#17 row 30:{"count":42,"items":["a","b"]}
#17 end 1 0 0:
Success without Data
return "done", nil, nil
Response. A command that reported nothing writes no record, and the terminator says so: zero records produced, zero rejected, and a message of zero bytes. Nothing is not the same answer as an empty collection:
#17 top doc 0: 0:
#17 end 0 0 0:
Success with Rows
A command that walks a large collection returns an sdk.Records rather than a
built value. The SDK writes one line for each row, so neither the plugin nor the
engine holds the whole answer:
return "done", sdk.Records{Key: "sessions", Rows: sessionRows()}, nil
#17 top map 8:sessions 0:
| | | | | |
| | | | | +----- column names, 0 BYTES, so the rows are not positional
| | | | +-------------- those 8 bytes: sessions, your Key
| | | +---------------- 8, the envelope name's BYTE count, then its colon
| | +-------------------- item type map: each record is one map of names to values
| +------------------------ kind top: the head
+---------------------------- correlation id 17
#17 row 26:{"id":1,"peer":"10.0.0.1"}
#17 row 26:{"id":2,"peer":"10.0.0.2"}
#17 end 2 0 0:
Each row states the 26 bytes of its payload and then those bytes. The
terminator counts two records produced, none rejected, and no message.
Key names the envelope the rows belong under. A handler MUST NOT name it
errors, because that name holds the rows the walk rejected. Rows is walked
once, before the handler's call returns. A handler MUST NOT store the sequence,
and MUST keep whatever it reads alive until then. A row that no wire message can
carry is reported as a rejected row, and the walk continues.
A walk of 256 rows or fewer collapses to one doc record, which is the
JSON the command answered with before it produced rows at all. The encoder
decides that from the walk, and the handler states nothing about the wire. That
document is one line as well, so rows that each fit and collapse into something
no line can carry earn the same rejected row, and the head then states map.
Handler Error
If the handler returns a non-nil error, the SDK sends an error response:
return "", nil, fmt.Errorf("operation failed: database timeout")
Response:
#17 error {"message":"operation failed: database timeout"}
ExecuteCommandInput Fields
The engine sends these fields in the execute-command RPC:
| Field | Type | Purpose |
|---|---|---|
serial |
string | Correlation ID for the request |
command |
string | Command name (e.g., "my-plugin status") |
args |
[]string | Additional arguments (may be empty) |
peer |
string | Peer selector (may be empty) |
Naming Conventions
| Pattern | Example | Purpose |
|---|---|---|
<plugin> status |
acme-monitor status |
Get current state |
<plugin> stats |
acme-monitor stats |
Get metrics |
<plugin> <action> |
acme-monitor check |
Perform action |
<plugin> list |
acme-monitor list |
List items |
Rules:
- Start with plugin name to avoid conflicts
- Use kebab-case for multi-word commands
- Keep commands short and memorable
Command Arguments
Arguments arrive in the args parameter of the handler:
p.OnExecuteCommand(func(serial, command string, args []string, peer string) (string, any, error) {
if command == "my-plugin get" {
if len(args) < 1 {
return "error", map[string]string{
"error": "usage: my-plugin get <key>",
}, nil
}
key := args[0]
value := getValue(key)
return "done", map[string]any{"key": key, "value": value}, nil
}
return "error", map[string]string{"error": "unknown command"}, nil
})
Invocation:
ze bgp run "my-plugin get config.timeout"
CommandDecl Fields
Commands are declared with these fields:
| Field | Type | Required | Purpose |
|---|---|---|---|
Name |
string | ✓ | Command name (for example, "my-plugin status") |
Description |
string | ✕ | The one-line SUMMARY, shown wherever the command appears on one line. One line, at most 256 bytes |
LongHelp |
string | ✕ | The LONG explanation this command's own help page prints, under the summary. At most 4096 bytes, newlines kept |
Args |
[]string | ✕ | Expected argument names (for help/completion) |
Completable |
bool | ✕ | Whether the command supports tab completion |
Shape |
string | ✕ | What the answer holds: doc, map or tab |
Columns |
[]string | ✕ | The answer's keys, in the order a person reads them. Needs a Shape that has rows |
AddressFields |
[]string | ✕ | The keys whose value holds an IP address or a prefix. Needs a Shape |
Description and LongHelp are two texts, and neither is derived from the
other. Write the summary as one sentence a reader meets in a list. Write the
explanation as the paragraphs they read when they ask about that one command.
Declare no LongHelp and the help page prints the summary alone.
The wire keys are description and long-help. The spelling is long-help
and not help, because help already names the summary in a completion row on
this same protocol.
Declaring What Your Answer Holds
Declare a shape and the CLI publishes the pipe operators your command supports, and refuses the others BY NAME before your handler runs. Declare nothing and the CLI waits for the answer, then refuses from what it has in hand.
| Shape | Your handler answers |
|---|---|
doc |
one document or one value. No row operator applies |
map |
rows that carry their own keys, such as a map of name to object |
tab |
rows read against the column names you declare |
err := p.Run(ctx, sdk.Registration{
Commands: []sdk.CommandDecl{{
Name: "my-plugin peers",
Description: "Show the sessions",
Shape: "tab",
Columns: []string{"address", "state", "up"},
AddressFields: []string{"address"},
}},
})
Four rules decide whether the engine accepts the declaration:
- The shape MUST be
doc,maportab. Any other spelling fails startup. - A
ColumnsorAddressFieldslist MUST come with aShape. - A column name MUST be a key your handler writes, in the same spelling. The engine cannot check this, and a name that names nothing orders nothing.
- One command declares at most 64 columns and 16 address fields, each name 1 to 64 bytes.
Declare one shape for every argument the command takes. A command answering a row set with no argument, and one bare object with an argument, declares neither branch truthfully. Make both branches answer rows.
AddressFields is an admission gate. It decides whether | resolve and
| origin run at all. It does not decide which values they decorate: both walk
every key of the answer and decorate each string that parses as an address.
Naming a Pipe Alias for Your Command
A pipe alias is the word an operator types after the pipe character, standing
for an operator chain they would otherwise type in full. Declare one in the
Pipes list of the same Registration:
err := p.Run(ctx, sdk.Registration{
Commands: []sdk.CommandDecl{
{Name: "my-plugin status", Description: "Show current status"},
},
Pipes: []sdk.PipeDecl{{
Command: "my-plugin status",
Name: "totals",
Description: "The counters, without the per-session rows",
Expansion: "display sessions-total sessions-established",
}},
})
| Field | Type | Required | Purpose |
|---|---|---|---|
Command |
string | ✓ | Command path the alias sits on. MUST be one of your own declared commands |
Name |
string | ✓ | The word typed after the pipe character (kebab-case) |
Description |
string | ✕ | The line completion and command help show beside the name |
Expansion |
string | ✓ | The operator chain the name stands for |
Three rules decide whether your command CAN have an alias.
- The pipe layer selects. It does not compute.
displaykeeps named keys and drops the rest, and no operator renames a key, adds two numbers, or counts matching rows. Your handler MUST emit the aggregate fields beside the detail rows, as siblings at one level.show bgp rpkiis the worked example. - The name MUST be free. It cannot be a built-in operator name, a pipe filter name on an overlapping command path, or an alias name the exact same command path already carries. One refusal fails your whole Stage 1 registration.
- The alias takes no argument, and names no other alias. Its expansion parses to built-in operators alone.
The engine stops the alias reaching a command below the one it sits on. Declare
my-plugin status detail in the same message and it inherits nothing.
ze cli with no command argument expands the chain in the client process, where
your alias is not registered, so it answers
pipe error: unknown pipe operator: totals there. It resolves over
ze cli -c "<command>" and in the interactive session a plain ssh client
reaches.
Complex Responses
Return structured JSON data for API consumers:
p.OnExecuteCommand(func(serial, command string, args []string, peer string) (string, any, error) {
if command == "monitor metrics" {
data := struct {
Checks int `json:"checks"`
Failures int `json:"failures"`
LatencyMs float64 `json:"latency-ms"`
LastCheck string `json:"last-check"`
}{
Checks: state.checks,
Failures: state.failures,
LatencyMs: state.latency,
LastCheck: state.lastCheck.Format(time.RFC3339),
}
return "done", data, nil
}
return "error", map[string]string{"error": "unknown command"}, nil
})
Note: JSON keys use kebab-case per ze conventions ("latency-ms", not "latency_ms").
Dispatching Commands to Other Plugins
Plugins can invoke commands on other plugins through the engine's command dispatcher:
p.OnStarted(func(ctx context.Context) error {
status, data, err := p.DispatchCommand(ctx, "rib show-in ipv4/unicast")
if err != nil {
return err
}
fmt.Printf("rib response: status=%s data=%s\n", status, data)
return nil
})
The engine routes the command by longest-match registry lookup and returns the full {status, data} response from the target handler.
Help Text
Provide usage information via a dedicated command:
// In Registration:
sdk.CommandDecl{Name: "my-plugin help", Description: "Show available commands"},
// In handler:
if command == "my-plugin help" {
return "done", map[string]any{
"commands": []string{
"my-plugin status - Show current status",
"my-plugin check <target> - Trigger immediate check",
"my-plugin metrics - Show performance metrics",
},
}, nil
}