Writing Your First Module
A module is an independent process that owns one domain and speaks JSON to core. This guide walks the whole shape using the real nginx module that ships with suctl — manifest, lifecycle, one capability, a surface.
A module never creates, binds, or dials a socket. Core spawns it and hands it
one bidirectional wire as an inherited file descriptor (SUCTL_BROKER_FD);
possession of that wire is the module’s identity. Everything below works
in any language that can read and write newline-delimited JSON.
1 · Layout
Section titled “1 · Layout”modules/suctl-mod-nginx/ manifest.json what this module is and can do surface.json its surface — survey + focus (optional) suctl-mod-nginx the entrypoint2 · Manifest
Section titled “2 · Manifest”The manifest declares identity, requirements, and every capability — core validates calls against it, so a capability without a declaration does not exist. Trimmed from the real module:
{ "version": "0.3.0", "protocol": "1", "platform": ["linux"], "author": "suctl", "license": "AGPL-3.0", "description": "Manages nginx virtual hosts and per-domain availability state.", "entrypoint": "suctl-mod-nginx", "requires": { "binaries": ["nginx"], "paths": ["/etc/nginx/sites-available", "/etc/nginx/sites-enabled"] }, "capabilities": [ { "name": "nginx.reload", "description": "Reload nginx configuration without dropping connections.", "async": false, "params": [] } ]}Interpreted languages: keep entrypoint a plain string, give the file a
shebang (#!/usr/bin/env python3), and chmod +x it.
3 · Lifecycle
Section titled “3 · Lifecycle”The process loop has four duties:
- Inherit the wire from
SUCTL_BROKER_FDand read JSON envelopes from it. - Answer
handshakewith the manifest, andhealthwith{"status": "healthy", "uptime_seconds": N}. - Route
invoketo the handler named byparams.name—UNKNOWN_CALLABLEif there isn’t one. - On
SIGTERM, finish in-flight calls and exit; the wire closes with you.
In Go, the SDK does all of it — the real module’s main is just handlers:
func main() { handlers := map[string]modserver.Handler{ "nginx.reload": cmdReload, "nginx.domain.list": cmdDomainList, // ... } if err := modserver.Serve(modserver.Config{ Manifest: manifestJSON, Handlers: handlers, }); err != nil { fmt.Fprintf(os.Stderr, "suctl-mod-nginx: %v\n", err) os.Exit(1) }}Other languages implement the same loop directly — the envelope format and error codes are specified in the wire protocol.
4 · A capability
Section titled “4 · A capability”A handler reads live state, acts, and answers honestly — success with a result, or a failure the operator can act on. Never a fabricated success:
func cmdReload(ctx context.Context, args map[string]interface{}) (interface{}, *errorDetail) { if ed := requireNginx(); ed != nil { return nil, ed } if ed := reloadOrPartial("reload"); ed != nil { return nil, ed } return okResult(map[string]interface{}{"reloaded": true})}5 · Surface
Section titled “5 · Surface”A module earns a row on the home page (REPL face today) by shipping surface.json and the
survey/focus capabilities it names — zero UI code; core owns the frame:
{ "surfaces": [ { "subject": "domain", "survey": { "entry": "nginx.domain.survey", "columns": [ { "id": "blocks", "label": "blocks" }, { "id": "ssl", "label": "ssl" }, { "id": "status", "label": "status" } ], "facets": [ { "label": "active", "value": "active" }, { "label": "ssl: expiring", "value": "ssl:expiring" } ] }, "focus": { "entry": "nginx.domain.focus" } } ]}The survey capability returns the live landscape; the focus capability returns
one subject’s detail plus the actions valid for its current state — a
suspended domain offers unsuspend and not suspend. Add the surface only
after survey and focus work.
Before you ship
Section titled “Before you ship”- Handshake returns the manifest; health answers correctly.
- Every declared capability has a handler; no handler is undeclared.
- The process never opens a socket of its own.
- Failures are reported as failures.
Rather than check these by hand, run the built-in self-test against your compiled binary — it spawns the module, performs the handshake, probes every declared capability, and confirms graceful shutdown:
suctl bist module ./suctl-mod-nginxIt ends in module WOULD activate under suctl boot; a non-zero exit means the
module would be rejected at boot. This is the same BIST core runs at
activation, so a local pass is a boot pass. For CI, the standalone
suctl-modtest ./suctl-mod-nginx writes the same report to stdout and exits
non-zero on any failure.
The normative specs: module author contract, manifest schema, surface schema, wire protocol, module lifecycle.