Documentation

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:

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:

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 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
}