Skip to content

Module Author Contract

This document defines what a module author must implement. For architecture and core behaviour, see module system. For manifest field definitions, see manifest schema. For the wire protocol, see wire protocol. For the lifecycle hook system, see module lifecycle.


When core starts your module process it has already:

  1. Created a private, address-less, bidirectional socketpair and kept one end.
  2. Passed your end to your process as an inherited file descriptor, named by the environment variable SUCTL_BROKER_FD (fd 3 on Unix; SUCTL_BROKER_FD_R / SUCTL_BROKER_FD_W on Windows).

Your process recovers that one wire and serves core’s inbound requests on it — the same wire it uses for outbound cross-module calls. There is no socket to bind, no path to listen on, no accept(). Possession of the inherited wire is your module’s identity. No socket setup boilerplate in any language.


ResponsibilityRequired?
manifest.json in your module directoryAlways
Serve the inherited broker wire (SUCTL_BROKER_FD)Always
handshake command handlerAlways
health command handlerAlways
invoke command handlerFor each declared capability
surface.json in your module directoryIf has surface
Survey entry capability declared and implemented (the one named in survey.entry)Per surface
Focus entry capability declared and implemented (the one named in focus.entry)Per surface

Full envelope format and command semantics: wire protocol.


manifest.json — Your Module’s Identity

Section titled “manifest.json — Your Module’s Identity”

Every module directory contains manifest.json. Core reads it from disk before starting your process. At handshake, core validates that your live manifest declares the same protocol version as the file on disk — identity comes from your module’s directory, not a manifest field (D55). A protocol mismatch is a hard error — your module is marked unavailable.

Full field definitions in manifest schema. The entrypoint field is required:

"entrypoint": "suctl-mod-nginx"
"entrypoint": ["python3", "main.py"]
"entrypoint": ["bash", "run.sh"]

String for compiled binaries. Array for interpreted languages — first element is the interpreter. Path is relative to your module directory. Core executes it exactly as declared.

Interpreted languages — use a shebang. If your entrypoint is a script, add a shebang as the first line of the file and make it executable (chmod +x). Declare the entrypoint as a plain string (same as a compiled binary) rather than an array:

"entrypoint": "suctl-mod-odoo" ← preferred: shebang in file, no array needed
"entrypoint": ["python3", "suctl-mod-odoo"] ← also valid, but redundant with a shebang

The shebang serves three purposes with one line: the OS uses it to invoke the correct interpreter, the IDE uses it for syntax highlighting and language tooling, and the filename stays extension-free — meaning the language is an implementation detail that can change without affecting any external reference (manifest, hooks, systemd drop-ins, docs).

#!/usr/bin/env python3 # Python
#!/usr/bin/env node # Node.js
#!/usr/bin/env ruby # Ruby
#!/usr/bin/env bash # Bash

Compiled languages (Go, Rust, C) produce a self-contained binary — no shebang, no array, just the binary name as a string entrypoint.


Core manages your module process. You do not manage your own restart or scheduling.

Always-on: Core starts your module at boot and keeps it running until core stops (D57). There is no idle shutdown — core never sends SIGTERM because your module is quiet.

Graceful shutdown on SIGTERM:

  1. Stop accepting new connections
  2. Complete any in-progress synchronous invocations
  3. Mark all running async jobs as failed with reason "module_shutdown"
  4. Exit

Crash recovery: If your process exits unexpectedly, core restarts it immediately. More than 3 restarts in 60 seconds → module marked FAILED, operator intervention required, no further automatic restarts.


Your module cannot speak to other modules directly. All cross-module calls go through core.

Send an invoke envelope back to core over your inherited broker wire (SUCTL_BROKER_FD) — the same wire core uses to call you, and using the same envelope format. Core routes it to the target module and returns the response. The wire is available from the moment your process starts.

Full broker envelope format and error responses: wire protocol.


A module with a surface has two files in its directory: manifest.json (operational contract) and surface.json (view contract). Core reads both at startup. If surface.json is absent, the module declares no surface — no error, no warning.

The surface.json schema is defined in surface schema. A module may declare multiple surfaces; each becomes its own home-page row with its own survey page.

A surface points at two capabilities through its survey.entry and focus.entry fields. These are ordinary capabilities — you declare and implement them in manifest.json like any other. Each face (the REPL today) reads the names from surface.json and invokes them by name; nothing assumes a fixed spelling.

RoleConstraints
Survey entry point — returns live subject listasync: false
Focus entry point — returns full subject detail and actionsasync: false

The recommended convention is {module-short}.{subject}.survey and {module-short}.{subject}.focus — for suctl-mod-nginx with a domain surface that is nginx.domain.survey and nginx.domain.focus. The convention keeps names predictable across the ecosystem; it is not parsed. The one hard requirement is that the capability each entry names is declared and implemented — an entry that names a capability not in the module’s active surface fails when a face invokes it.

A survey column may declare a from capability that fills its cells after the survey resolves — a whole-column data source fired once per distinct from (not once per row), returning values for every row at once. This lets a column come from a different read than the survey itself (for example, a per-database overview the bare list does not carry). The field and the row-keyed response shape are defined in surface schema.

Ambient state, read live, rendered in one pass. The operator lands here.

The survey capability receives no filter arguments — modules are never told which facets the operator has active. It returns all subjects, each tagged with a facets array listing every declared facet value that applies to that row. Core filters the cached subject list locally on chip toggle; no round-trip to the module occurs. total must always reflect the full unfiltered count. The full response contract is defined in surface schema.

Survey shows what this module knows from its own source of truth. nginx reads its config tree and certificate files on disk. DNS status is not nginx’s knowledge domain. Each module surveys its own domain honestly.

Everything this module knows about the selected subject, plus its own available actions filtered by current live state.

The focus capability receives { "subject": "<id>" } where id is the opaque identifier returned in the survey response. It returns the full response contract defined in surface schema, including a sections[] array of KV groups and an actions[] array computed from live subject state.

Focus shows only the actions that apply right now. Actions that do not apply are absent — not greyed out, not disabled.

A module may expose hierarchical navigation by nesting child surfaces in a drills[] array inside a parent surface in surface.json. The parent surface appears on the home page; drill children are reachable only from a parent row. The full schema is in surface schema.

Drill survey capabilities are ordinary capabilities. You declare odoo.module.survey in manifest.json just like any other survey capability. The only difference is that core passes a scope argument:

{ "scope": "prod" }

scope is the opaque id of the selected parent row. Your survey handler reads it to scope results to that parent, then returns all matching rows tagged with applicable facets — same contract as a top-level survey:

# Python
scope = args.get("scope", "") # e.g. "prod" (selected database id)
modules = client.list_modules(db=scope)
// Go
scope, _ := args["scope"].(string) // e.g. "prod"
modules := client.ListModules(scope)

ID minting and lineage. Because scope at depth N+1 is just the id minted at depth N, encode lineage in the ids you mint. A module row under database prod might mint id prod/sale — at the next drill level the child survey receives scope = "prod/sale" and can reconstruct both ancestors without core carrying an ancestor stack. Core treats every id as an opaque string.

Drill relationships are static. A drill expresses a type relationship (database always has module), not a per-row state gate like an inline action. Declare drills in surface.json and express per-row availability through inline actions or focus actions.


Go module authors import the public SDK instead of hand-coding JSON envelopes and raw map types. The SDK is the stable contract between core and modules; internal packages must never be imported by external module binaries.

Module path: github.com/solutionsunity/suctl/suctl/sdk

Add to your module’s go.mod:

require github.com/solutionsunity/suctl/suctl/sdk v0.0.0
replace github.com/solutionsunity/suctl/suctl/sdk => ../../sdk

Wire types and result helpers. Replaces hand-coded struct definitions and bare string error codes.

SymbolDescription
protocol.Request / Response / ErrorDetailEnvelope types. Decode incoming requests; encode responses.
protocol.InvokeRequest / InvokeResponseParams and result shapes for invoke commands.
protocol.OKResult(v)Marshals v to JSON; returns (json.RawMessage, nil). Use as the success return of a capability handler.
protocol.FailResult(code, msg)Returns (nil, *ErrorDetail). Use as the error return of a capability handler.
protocol.Code* constantsStandard error codes (CodeInvalidParams, CodeCallableFailed, CodeInternalError, etc.). Always use these instead of bare strings.

Typed survey and focus response structs. Replaces map[string]interface{} in survey and focus handlers.

SymbolDescription
surface.SurveyResponseReturn type for {module}.{subject}.survey. Fields: Total, StatusSummary, Subjects.
surface.SubjectOne survey table row. Fields: ID, Name, Columns, Facets, InlineActions.
surface.ColumnCell value + color. Use surface.Col(value, color) as a shorthand constructor.
surface.FocusResponseReturn type for {module}.{subject}.focus. Fields: ID, Name, Sections, Actions.
surface.SectionTitled group of fields in the focus overlay. Fields: Title, Fields.
surface.FieldLabeled field. Fields: Label, Value, Color, FullWidth.
surface.ActionCapability button for inline or focus actions. Fields: Capability, Label, Destructive. Button color is owned by the face (destructive vs safe), not the module.

surface.SurveyResponse.StatusSummary is an optional free-form string that the REPL renders in two places: as the second line under a module row on the Home page, and as a dedicated informational row at the bottom of the in-box view when the operator is inside that module’s survey.

Fallback precedence — REPL applies the first non-empty value it finds to the summary row:

  1. Module-supplied StatusSummary — wins unconditionally. Use this for domain-specific operational context (e.g. "8 domains · 2 ssl expiring"). The string is treated as opaque text; language, format, and content are entirely the module author’s choice.
  2. Bare count fallback — when StatusSummary is empty and Total > 0 the REPL renders "%d %s" using Total and the module’s subject from surface.json. Example: "12 domain".
  3. Empty — when Total is 0 or subject is not declared an empty line is drawn to keep a stable UI height.

Colour semantics — a non-empty StatusSummary renders in amber (“warn”), signalling an operational alert. The bare count fallback renders in dim text, indicating informational-only context with no alert implied.

Filtering in the REPL happens entirely in core — the module is never called again when the operator toggles a facet chip or types a text search.

  • Facets are classification tags the module places on each subject row. Declare the vocabulary in surface.json survey.facets; tag each returned subject with whichever declared values apply. Core AND-combines the active chips against the cached tags and updates the visible rows without a network call. StatusSummary is computed over the full unfiltered set and is never affected by facet state.
  • Free-text search is applied by core to the same cached subject list, also locally. The module never sees search text.

A count badge (N of M) appears whenever any filter (facet or text) is active. M is total from the survey response — it is invariant.

Helper for calling other modules’ capabilities over the inherited broker wire. Handles envelope construction, job_token generation and propagation, and error unpacking.

import "github.com/solutionsunity/suctl/suctl/sdk/brokerclient"
// In a handler, pass the received ctx so the cross-module call re-enters the
// originator's job bucket (R-E1) instead of minting a fresh job_token.
out, err := brokerclient.InvokeContext(ctx, "certbot.cert.provision", map[string]interface{}{
"domain": "example.com",
})

A compound capability is composed in plain module code. The handler calls other capabilities — its own directly, others’ through the broker client — and passes results forward as ordinary code. There is no manifest step list, no standardized runner, and no engine. The composition is implementation, declared in the manifest only by its name and signature.


Action availability is computed in the focus handler — not declared in the manifest. The focus handler returns the actions array filtered to the actions that apply to the current subject state. Core renders exactly what the handler returns; it does not evaluate any availability expression.

A read-only module returns an empty or absent actions array. A module with mutable state computes which actions apply by reading live subject state in the handler and including only those that are valid right now.


A surface module that builds its surveys and focuses entirely by calling other modules’ capabilities through core. It may have no capabilities of its own.

suctl-mod-sites calls nginx.domain.list, apache.vhost.list, and caddy.site.list for its survey — whichever providers are active. For SSL it calls certbot.ssl.provision or cloudflare.ssl.provision based on which is active. It has its own surface. nginx, apache, caddy, certbot, and cloudflare remain independent with their own surfaces.

When multiple providers are active, capability routing is the module author’s implementation detail — typically resolved by checking the live capability surface with module config as tiebreaker.


Modules may declare hooks — callables suctl invokes at specific lifecycle events such as pre-activate, pre-deactivate, post-deactivate, and on-crash. Hooks are optional. A module with no hooks block is unaffected by the hook system.

A hook is either:

  • exec — a script in the module directory, run with the module directory as working directory
  • capability — a capability declared by the same module (only valid when the process is already running)

Hook obligations:

  • Hooks must be idempotent — they may be called more than once and must not fail because a previous run already did the work
  • Blocking hooks must complete within their declared timeout_seconds
  • Exit code 0 = success. Non-zero = failure. suctl acts on failure per event semantics.

Full hook taxonomy, execution contract, and patterns in module lifecycle.


  • Cannot inject into another module’s surface. Each module owns its surface completely.
  • Cannot speak to other modules directly. All capability calls go through core over the inherited broker wire.
  • Cannot add state to core. Modules are stateless processes.
  • Cannot auto-activate themselves. No module auto-activates. Core capabilities are always available because they are core — not a module.
  • Cannot use the system.* capability namespace. That namespace is reserved for core. A module that declares a capability beginning with system. is rejected at startup.
  • Cannot declare a partial surface. If surface.json is present, every surface must declare both survey.entry and focus.entry (and implement the capabilities they name). A surface that declares one without the other fails to parse — the surface is dropped and a warning is recorded.
  • Cannot survive removal from disk while activated. Missing directory means capabilities absent and operator notified immediately.
  • Cannot create capability name conflicts. Global uniqueness is structural — every capability name is prefixed with the module short name.
  • Cannot use a capability hook form for events that fire before the module process is running (pre-activate, on-requirement-missing). Use exec for those events.