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.
What Core Provides at Startup
Section titled “What Core Provides at Startup”When core starts your module process it has already:
- Created a private, address-less, bidirectional socketpair and kept one end.
- 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_Won 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.
What You Must Implement
Section titled “What You Must Implement”| Responsibility | Required? |
|---|---|
manifest.json in your module directory | Always |
Serve the inherited broker wire (SUCTL_BROKER_FD) | Always |
handshake command handler | Always |
health command handler | Always |
invoke command handler | For each declared capability |
surface.json in your module directory | If 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 shebangThe 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 # BashCompiled languages (Go, Rust, C) produce a self-contained binary — no shebang, no array, just the binary name as a string entrypoint.
Process Lifecycle
Section titled “Process Lifecycle”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:
- Stop accepting new connections
- Complete any in-progress synchronous invocations
- Mark all running async jobs as
failedwith reason"module_shutdown" - 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.
Calling Other Modules’ Capabilities
Section titled “Calling Other Modules’ Capabilities”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.
Surface Structure
Section titled “Surface Structure”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.
Survey and focus entry capabilities
Section titled “Survey and focus entry capabilities”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.
| Role | Constraints |
|---|---|
| Survey entry point — returns live subject list | async: false |
| Focus entry point — returns full subject detail and actions | async: 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.
Column data sources (from)
Section titled “Column data sources (from)”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.
Survey — the entry point
Section titled “Survey — the entry point”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.
Focus — the subject view
Section titled “Focus — the subject view”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.
Drills — scoped child surfaces
Section titled “Drills — scoped child surfaces”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:
# Pythonscope = args.get("scope", "") # e.g. "prod" (selected database id)modules = client.list_modules(db=scope)// Goscope, _ := 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 SDK
Section titled “Go SDK”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.0replace github.com/solutionsunity/suctl/suctl/sdk => ../../sdksdk/protocol
Section titled “sdk/protocol”Wire types and result helpers. Replaces hand-coded struct definitions and bare string error codes.
| Symbol | Description |
|---|---|
protocol.Request / Response / ErrorDetail | Envelope types. Decode incoming requests; encode responses. |
protocol.InvokeRequest / InvokeResponse | Params 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* constants | Standard error codes (CodeInvalidParams, CodeCallableFailed, CodeInternalError, etc.). Always use these instead of bare strings. |
sdk/surface
Section titled “sdk/surface”Typed survey and focus response structs. Replaces map[string]interface{} in survey and focus handlers.
| Symbol | Description |
|---|---|
surface.SurveyResponse | Return type for {module}.{subject}.survey. Fields: Total, StatusSummary, Subjects. |
surface.Subject | One survey table row. Fields: ID, Name, Columns, Facets, InlineActions. |
surface.Column | Cell value + color. Use surface.Col(value, color) as a shorthand constructor. |
surface.FocusResponse | Return type for {module}.{subject}.focus. Fields: ID, Name, Sections, Actions. |
surface.Section | Titled group of fields in the focus overlay. Fields: Title, Fields. |
surface.Field | Labeled field. Fields: Label, Value, Color, FullWidth. |
surface.Action | Capability button for inline or focus actions. Fields: Capability, Label, Destructive. Button color is owned by the face (destructive vs safe), not the module. |
Status Summary
Section titled “Status Summary”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:
- 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. - Bare count fallback — when
StatusSummaryis empty andTotal > 0the REPL renders"%d %s"usingTotaland the module’ssubjectfromsurface.json. Example:"12 domain". - Empty — when
Totalis 0 orsubjectis 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 contract
Section titled “Filtering contract”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.jsonsurvey.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.StatusSummaryis 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.
sdk/brokerclient
Section titled “sdk/brokerclient”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",})Compound capabilities
Section titled “Compound capabilities”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
Section titled “Action Availability”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.
Aggregator Modules
Section titled “Aggregator Modules”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.
Lifecycle Hooks
Section titled “Lifecycle Hooks”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 directorycapability— 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.
What Modules Cannot Do
Section titled “What Modules Cannot Do”- 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 withsystem.is rejected at startup. - Cannot declare a partial surface. If
surface.jsonis present, every surface must declare bothsurvey.entryandfocus.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
capabilityhook form for events that fire before the module process is running (pre-activate,on-requirement-missing). Useexecfor those events.