Skip to content

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.

modules/suctl-mod-nginx/
manifest.json what this module is and can do
surface.json its surface — survey + focus (optional)
suctl-mod-nginx the entrypoint

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.

The process loop has four duties:

  1. Inherit the wire from SUCTL_BROKER_FD and read JSON envelopes from it.
  2. Answer handshake with the manifest, and health with {"status": "healthy", "uptime_seconds": N}.
  3. Route invoke to the handler named by params.nameUNKNOWN_CALLABLE if there isn’t one.
  4. 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.

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

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.

  • 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:

Terminal window
suctl bist module ./suctl-mod-nginx

It 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.