Skip to content

Module System


The unit a module provides. A capability has a name, a signature, and a result. It is atomic or compound:

  • Atomic — an indivisible operation. Single responsibility. Reads live state. Returns a result. Independently correct and independently testable.
  • Compound — composed from other capabilities, its own or other modules’. The composition lives entirely in module code. No engine routes between steps; the module process calls each capability in turn and passes outputs forward.

Examples: nginx.reload, system.service.restart, nginx.domain.create (atomic); nginx.provision — creates the vhost, provisions SSL, reloads — (compound).

From the caller’s perspective there is no distinction. Both are declared as capabilities in the manifest, both are called by name, both have params and a result. Atomic or compound is an implementation fact, not a protocol fact.

The module’s operator-facing view contract — what the module exposes for operators to navigate. Two required parts:

  • Survey — ambient state view. The entry point. Mandatory.
  • Focus — subject-aware detail view. Mandatory.

Survey and focus are always present together or not at all. A module may declare more than one surface, each scoped to a distinct subject type. The system module (core) itself declares surfaces — module (Inventory), job (Jobs), and message (Messages) — reached from the home page; the job surface drills into a job’s recorded exchanges. A read-only module shows reality and does not act on it.

A surface is declared in surface.json and rendered by a face — the REPL is the first face today; future faces (web, API) render the same surfaces without the module changing. Full surface contract in surface schema; the face role is defined in core architecture.

Per-subject action availability is computed by the module’s focus handler, which returns the resolved set of actions for a given subject. Availability logic lives in module code — not in a manifest expression evaluated by core. Optional — absent for read-only modules, which show reality without acting on it.


Classification follows from what a module implements. There is no type field. The manifest structure is the truth.

Util module — provides capabilities for other modules to use, with no surface. No survey, no focus, no operator-navigation presence. Its capabilities are consumed by other modules in any context — composition, survey reads, focus views.

Examples: suctl-mod-certbot, suctl-mod-systemd, suctl-mod-postgres

Surface module — declares one or more surfaces (survey + focus) plus whatever capabilities it needs. Appears in operator navigation when activated.

Examples: suctl-mod-nginx, suctl-mod-odoo, suctl-mod-fail2ban

A surface module that builds its surveys and focuses entirely by calling other modules’ capabilities — with no direct system reads of its own — is an aggregator. Structurally identical to any other surface module.

Example: suctl-mod-sites — unifies HTTP hosting (nginx, apache, caddy) and SSL (certbot, cloudflare) under one operator interface. The operator manages sites; which web server or SSL provider is active is the module’s concern, not the operator’s.

The modules survey shows each module’s actual composition: how many capabilities and surface actions it has. Type is derived from these numbers when displayed. It is never stored or declared.


suctl core runs as a single process. Each module runs as an independent process. Core communicates with each module over a private bidirectional socketpair it creates at spawn and hands the child as SUCTL_BROKER_FD, using the protocol defined in wire protocol. Core’s own capabilities (system.module.*, system.version) are in-process — they are core, not a module.

The module protocol is the only contract between core and modules. Each module binary is compiled independently. Any language that can speak a Unix domain socket and exchange JSON envelopes can implement a module. suctl-mod-odoo is Python. Future modules may be any language.

Each active module is a child process with its own memory space. A misbehaving module cannot corrupt core or other modules. Memory and CPU scale with the number of active modules — operators activate only what they need.


When a module uses another module’s capability, it calls it by name through core. The called module has no awareness of the caller. No inheritance chain. No override.

Chains are normal. webapps.provision calls nginx.provision which calls certbot.ssl.provision. Three separate processes, three invocations, each correct independently. Each link names the next exactly.

Data flows explicitly. A compound capability’s composition lives in module code: the module calls each capability in turn and passes outputs forward as it sees fit. There is no manifest step list and no engine routing between steps. A later call does not re-read what an earlier one already produced.

Names are globally unique by construction. nginx.provision and apache.provision are different names. A caller names exactly what it calls.


Format: {module}.{action}, {module}.{domain}.{action}, or up to {module}.{domain}.{subdomain}.{action}.

  • module — the short name, without the suctl-mod- prefix.
  • domain — the subject area, used when a module manages more than one. Omitted when the action belongs directly to the module.
  • subdomain — a further narrowing, used only when an action must be scoped within a domain.
  • action — the verb.

Two to four segments. Three is the common case. The fourth segment is permitted only when scope genuinely narrows — for example a domain-scoped action that also exists at module level.

nginx.reload ← two parts: module-level action
nginx.domain.list ← three parts: action targets the domain subject
nginx.domain.create ← three parts
nginx.ssl.renew ← three parts: action targets the ssl subject
nginx.maintenance.on ← three parts: maintenance for the whole module
nginx.domain.maintenance.on ← four parts: maintenance scoped to a single domain
certbot.ssl.provision ← three parts
odoo.module.install ← three parts
odoo.db.create ← three parts
os.service.restart ← three parts: suctl-mod-os capability
system.module.activate ← three parts: core capability (not a module)
network.dns.check ← three parts
network.ping ← two parts: module-level action

Five or more segments are not accepted without a rigid written justification and approval. Depth is the exception, not the reach.

Capabilities are public by default — callable by any module that declares them in requires.capabilities. Anything not declared as a capability does not exist from core’s perspective and cannot be routed to; there is no private field.


A module is a directory containing manifest.json and an executable entrypoint. Core reads the manifest from disk before starting the module process. Full manifest schema in manifest schema.

The manifest covers: version, protocol, platform, entrypoint, requirements (binaries, paths, sockets, permissions, other capabilities, config keys), capabilities, and optionally hooks. Module identity is derived from the directory name, not declared in the manifest. Surface configuration lives in a separate surface.json file (surface schema).

/usr/lib/suctl/modules/ shipped modules — installed with suctl
/usr/local/lib/suctl/modules/ default third-party path
[operator-declared paths] additional paths in /etc/suctl/suctl.conf
/etc/suctl/suctl.conf
module_paths:
- /opt/custom/suctl/modules

The same module name appearing in two paths is a name conflict. Core marks the module unavailable with a reason naming both paths, records a warning, and does not index the duplicate. The operator resolves by removing one of the conflicting installations.

Core discovers modules, checks requirements, and surfaces what is ready. Activation requires explicit operator action. Core capabilities (system.module.*) are always available — they are in-process and require no activation step. When the operator activates a module whose required capabilities are provided by other ready-but-not-active modules, core returns CONFIRMATION_REQUIRED listing every module that will enter the active set; activation proceeds only after the operator confirms the named closure. Activation lifecycle hooks (pre-activate, post-activate, on-activate-fail, …) are described in module lifecycle.

core at startup:
phase 1 — discovery:
scan module_paths for directories containing manifest.json
load all manifests from disk into the module index
phase 2 — missing check:
for each previously activated module not found in phase 1:
mark missing — capabilities absent, operator notified
phase 3a — evaluate requirements (two-phase, cascade-aware):
pass 1: for each discovered module, check system requirements
(binaries, paths, sockets) against the live system —
any miss marks the module unavailable
pass 2: build the ready capability surface from modules that
survived pass 1; for each remaining module check
requires.capabilities against that surface
(cascading: if certbot is unavailable because its
binary is missing, nginx requiring certbot.cert.provision
is also marked unavailable with a reason naming the
provider and its state)
phase 3b — for each discovered module:
not previously activated → mark ready, do not start
previously activated:
core-managed modules:
create private bidirectional socketpair; pass child end via SUCTL_BROKER_FD
launch process using entrypoint declared in manifest.json
wait for handshake; validate live manifest protocol matches manifest.json
timeout or manifest mismatch → mark unavailable, record reason
success → activate, promote capabilities from ready to active

Surface modules that are ready but not activated are visible and activatable from the core Inventory view (provided by the system module). Util modules have no surface — their activation status is visible through the Inventory.

Activation state lives in /var/lib/suctl/modules/ — written by suctl, read by the operator through suctl.

StateMeaningReason recorded
readyRequirements met. Not yet activated by operator.No
activeRequirements met, activated, process running, capabilities registered.No
unavailableCannot be activated. Reason always present.Yes — always
missingWas activated. Directory or manifest.json no longer on disk.Yes

Common unavailable reasons:

"required binary not found: nginx"
"required path not found: /etc/nginx/sites-available"
"required config key not set: cloudflare.api_token"
"module process not running"
"manifest mismatch"

A module that is installed but whose process failed to start or timed out during handshake is unavailable with reason "module process not running". It is not a distinct state.

Each module owns one file in /etc/suctl/conf.d/. Core reads the directory live. Each file is the truth for that module’s configuration — operator-written, independently auditable, independently removable.

# suctl-mod-cloudflare.conf
# Required: Cloudflare API token with Zone.DNS read and edit permissions
# cloudflare.api_token =
# Optional: request timeout in seconds (default: 10)
cloudflare.timeout = 10

manifest.json on disk is the complete contract between the module and core: what the module provides, what it needs, and how it presents itself to the operator. Core reads it at startup before starting any module process. The live manifest returned at handshake must declare the same protocol version. Full field definitions in manifest schema.


The same module name appearing in two paths is a conflict. Core marks the module unavailable with a reason naming both paths, records a warning, and does not index the duplicate — startup continues for every other module. The operator resolves by removing the conflicting installation.

Capability name conflicts are structurally impossible. Every capability name includes the module prefix. Two different modules cannot produce the same capability name.

There is no override mechanism. A module that wants different behavior declares its own capability with its own name.


A compound capability calls other capabilities. The module declares every cross-module capability it needs in requires.capabilities. Core checks the live capability surface at execution time. A capability is available when everything it requires is active; when any requirement is missing, the capability is absent — with the missing capability named.


The network module reads what the outside world sees about a domain or IP. DNS, HTTP, ping, TLS — all executed locally, measuring external visibility. The measurement is local. The subject is external.

Network capabilities are always async. Survey fields show checking... until resolved. Timeouts show unreachable — never a cached value.

The network module has its own survey. It does not inject into nginx’s survey. Two honest views of the same subject from different knowledge domains, navigated independently.


Modules may declare hooks — callables that suctl invokes at specific orchestration events. A hook is how a module expresses what preparation, cleanup, or reconciliation it needs at each lifecycle transition. suctl knows when to call; the module knows what to do.

Hooks are declared in the manifest under the hooks key. A module with no hooks key behaves exactly as before — all hooks silently absent. Full hook taxonomy, execution contract, and patterns in module lifecycle.


Core speaks protocol v1. Modules declare protocol v1 compatibility. Protocol changes are significant events requiring a decision entry. See wire protocol.