Manifest Schema
This document defines every field in the module manifest: its name, type, whether it is required, its semantics, valid values, and what core does with it. Module authors read this document to write correct manifests. Core validates manifests against this schema at handshake time.
Overview
Section titled “Overview”The manifest is a JSON document stored as manifest.json in the module directory. Core reads it from disk at startup — no module process required. Core itself has no manifest; its capabilities are registered in-process (D43). At handshake, core validates that the manifest the running module returns matches the file on disk exactly. A mismatch is a hard error: module marked unavailable. The disk file is the source of truth.
Core reads the manifest fresh at each startup. It is not cached between runs. If a module’s manifest changes, the next core startup reflects it.
Top-Level Fields
Section titled “Top-Level Fields”Module identity — derived, not declared
Section titled “Module identity — derived, not declared”There is no module field. A module’s identity is its directory name: the base name with the suctl-mod- prefix stripped is the module’s short name (D55). The short name is the capability namespace prefix and names the module’s config file (/etc/suctl/conf.d/suctl-mod-{name}.conf). The same short name appearing in two module paths is a conflict — core marks the module unavailable with a reason naming both paths, records a warning, and does not index the duplicate.
version
Section titled “version”- Type: string
- Required: yes
- Format: semver —
MAJOR.MINOR.PATCH - Semantics: Module version. Reported to suctl-server as part of the agent capability surface. Used in conflict reporting and operator information. Core does not enforce version constraints between modules — capability-based dependencies have no version component.
- Example:
"1.2.0"
protocol
Section titled “protocol”- Type: string
- Required: yes
- Format:
"1"(current protocol version) - Semantics: The module protocol version this module speaks. Core rejects modules declaring an unknown protocol version with a clear error. Protocol versions are integers — no semver for the protocol itself.
- Example:
"1" - See: wire protocol for full protocol definition.
platform
Section titled “platform”- Type: array of strings
- Required: yes
- Valid values:
"linux","windows","darwin" - Semantics: Platforms this module supports. A module whose platform list does not include the host OS is indexed unavailable with a reason naming the supported platforms — never silently hidden, so the inventory honestly reflects every installed module regardless of host OS.
- Example:
["linux"]
author
Section titled “author”- Type: string
- Required: yes
- Semantics: Module author or organization. Displayed in the modules survey and in conflict reporting. Free-form string.
- Example:
"suctl"/"Acme Corp"/"github.com/someuser"
license
Section titled “license”- Type: string
- Required: yes
- Semantics: SPDX license identifier. Displayed in modules survey. Core does not enforce license compatibility — this is informational for the operator.
- Example:
"AGPL-3.0"/"MIT"/"proprietary"
description
Section titled “description”- Type: string
- Required: yes
- Semantics: One sentence describing what this module manages. Shown in the modules survey unavailable and ready states. Must be operator-facing — describe the domain, not the implementation.
- Example:
"Manages nginx virtual hosts, SSL certificates, and site configuration"
entrypoint
Section titled “entrypoint”- Type: string or array of strings
- Required: yes
- Semantics: The command core uses to start the module process. String for a compiled binary — path relative to the module directory. Array for interpreted languages — first element is the interpreter, remaining elements are arguments and the script filename, all resolved relative to the module directory. Core executes exactly as declared, with the module directory as working directory. No PATH scanning. No convention. No guessing.
- Examples:
"suctl-mod-nginx"— compiled binary in module directory["python3", "main.py"]— Python script["bash", "run.sh"]— shell script
- See: module author contract for full entrypoint contract.
requires
Section titled “requires”Declares what must exist on the system for this module to function. Core evaluates all requirements against live system state before surfacing the module as ready. A module with unmet requirements is shown as unavailable with the specific unmet requirement listed.
requires.binaries
Section titled “requires.binaries”- Type: array of strings
- Required: no (default: empty)
- Semantics: Executable names that must be present in PATH or at known system locations. Core checks existence, not version.
- Example:
["nginx", "openssl"]
requires.paths
Section titled “requires.paths”- Type: array of strings
- Required: no (default: empty)
- Semantics: Filesystem paths that must exist. Directories or files. Core checks existence and read permission for the suctl user.
- Example:
["/etc/nginx/sites-available", "/etc/nginx/sites-enabled"]
requires.sockets
Section titled “requires.sockets”- Type: array of strings
- Required: no (default: empty)
- Semantics: Unix socket paths that must exist and be accessible. Used for modules that communicate with other local services via their native sockets (e.g., suctl-mod-fail2ban requires the fail2ban daemon socket).
- Example:
["/var/run/fail2ban/fail2ban.sock"]
requires.permissions
Section titled “requires.permissions”- Type: array of strings
- Required: no (default: empty)
- Semantics: Declared permissions the module needs beyond standard file access. Valid values:
"sudo"(requires sudo access for specific commands — must be paired with sudoers configuration deployed by Ansible),"docker-socket"(requires access to/var/run/docker.sock). Core checks whether declared permissions are available to the suctl user. - Example:
["sudo"]
requires.capabilities
Section titled “requires.capabilities”- Type: array of strings
- Required: no (default: empty)
- Semantics: Capability names from other modules that must be active for this module to function. Core checks the live capability surface after all other modules are loaded. A module with unmet capability requirements is surfaced as unavailable with the specific missing capability listed. This is the only legitimate form of inter-module dependency — and it is a runtime check, not a version constraint.
- Example:
["certbot.cert.provision"]
requires.config
Section titled “requires.config”- Type: array of objects
- Required: no (default: empty)
- Semantics: Config keys this module requires or accepts. Core validates required keys are present in the module’s conf.d file before activation. Each object:
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Config key name, namespaced: {module-short-name}.{key} |
required | boolean | yes | If true, module will not activate without this key |
secret | boolean | no | If true, value is masked in logs and REPL output |
default | string | no | Default value if not set. Only valid when required: false |
description | string | yes | Operator-facing description of what this key configures |
- Example:
"config": [ { "key": "cloudflare.api_token", "required": true, "secret": true, "description": "Cloudflare API token with Zone.DNS read and edit permissions" }, { "key": "cloudflare.timeout", "required": false, "secret": false, "default": "10", "description": "Request timeout in seconds" }]capabilities
Section titled “capabilities”Array of capability declarations. Each capability is an atomic operation this module provides.
Capability object fields
Section titled “Capability object fields”| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Full namespaced name: {module-short-name}.{action} or {module-short-name}.{domain}.{action}. See naming below. |
description | string | yes | One sentence, operator-facing. What this capability does. |
async | boolean | no | If true, core handles as async (returns job token, module pushes updates). Default: false (synchronous). |
params | array | no | Parameter declarations. See params object below. |
Capability naming
Section titled “Capability naming”Format: {module-short-name}.{action} or {module-short-name}.{domain}.{action}
module-short-name: the module name withoutsuctl-mod-prefix.nginxforsuctl-mod-nginx.domain: the noun being operated on — used when the module manages multiple distinct subject areas.domain,ssl,db,module,service. Omitted when the action belongs directly to the module itself.action: the verb.list,create,delete,reload,install,upgrade,status.
Examples:
nginx.reload ← two parts: reload belongs to nginx itself, no subjectnginx.domain.list ← three parts: list targets the domain subjectnginx.domain.create ← three partsnginx.ssl.renew ← three parts: renew targets the ssl subjectcertbot.ssl.provision ← three partsodoo.module.install ← three partsodoo.db.create ← three partssystem.service.restart ← three partssystem.disk.usage ← three partsnetwork.dns.check ← three partsnetwork.ping ← two parts: ping belongs directly to networkcloudflare.proxy.toggle ← three partsParams object fields
Section titled “Params object fields”| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Parameter name |
type | string | yes | string, boolean, integer, array |
required | boolean | yes | If false, parameter is optional |
description | string | yes | Operator-facing description |
default | any | no | Default value. Only valid when required: false |
Example capability declaration
Section titled “Example capability declaration”"capabilities": [ { "name": "nginx.domain.create", "description": "Create a new nginx virtual host configuration", "async": false, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Primary domain name" }, { "name": "root_path", "type": "string", "required": true, "description": "Document root filesystem path" }, { "name": "template", "type": "string", "required": false, "default": "default", "description": "Config template name" } ] }, { "name": "nginx.ssl.renew", "description": "Renew SSL certificate for a domain", "async": true, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Domain name to renew certificate for" } ] }]Surface configuration
Section titled “Surface configuration”Surface configuration does not live in manifest.json. It lives in a separate file — surface.json — in the same module directory. A module without surface.json is a util module — present in the capability surface but declaring no surface. A module with surface.json declares one or more surfaces; each surface is rendered as a row on the home page (REPL face today) and opens its own survey page.
This separation keeps manifest.json focused on the module’s operational contract (identity, capabilities, requirements, hooks) and surface.json focused on the view contract (subjects, columns, filters, entry points).
Full surface.json schema and field definitions: surface schema.
Each surface in surface.json names two capabilities through its survey.entry and focus.entry fields, and both must be declared in this file’s capabilities array and implemented:
- survey entry point —
async: false. - focus entry point —
async: false.
The names are the module author’s choice. The recommended convention is {module-short}.{subject}.survey / {module-short}.{subject}.focus (see surface schema); faces invoke whatever name entry carries — the pattern is not parsed.
Optional. Declares callables that suctl invokes at specific lifecycle events. A module with no hooks key is unaffected by the hook system.
Each key is a lifecycle event name. The value declares what suctl calls when that event fires.
Hook object fields
Section titled “Hook object fields”| Field | Type | Required | Description |
|---|---|---|---|
exec | string | one of exec/capability required | Path to a script relative to the module directory. suctl runs it with the module directory as working directory. |
capability | string | one of exec/capability required | Capability name declared by this module. Called via the module process. Only valid for events where the module process is already running. |
timeout_seconds | integer | no | For blocking hooks: how long suctl waits before proceeding. Default: 30. |
Valid event names
Section titled “Valid event names”| Event | Blocking | capability form allowed |
|---|---|---|
on-requirement-missing | Yes | No — process not running |
pre-activate | Yes | No — process not running |
post-activate | Yes | Yes |
on-activate-fail | No | No — process may not be running |
pre-deactivate | Yes | Yes |
post-deactivate | No | No — process has exited |
on-health-fail | No | Yes |
on-health-recover | No | Yes |
on-crash | No | No — process has exited |
Full event semantics, execution contract, and patterns: module lifecycle.
Example
Section titled “Example”"hooks": { "pre-activate": { "exec": "hooks/pre-activate.sh", "timeout_seconds": 60 }, "pre-deactivate": { "exec": "hooks/pre-deactivate.sh", "timeout_seconds": 30 }, "on-crash": { "exec": "hooks/on-crash.sh" }, "on-health-fail": { "capability": "odoo.hook.health-fail" }}
socket_pathfield does not exist. Every module is core-managed: core spawns it and passes an inherited socketpair viaSUCTL_BROKER_FD(fd 3 on Unix;SUCTL_BROKER_FD_R/SUCTL_BROKER_FD_Won Windows). The module owns that wire for its lifetime — no bind or listen needed. A module may create or attach to sockets inside external services (e.g. Odoo, fail2ban) for its domain work — those sockets are outside suctl’s boundary and are never declared in the manifest.
overridesfield does not exist. A module that wants different behavior declares its own capability with its own name. There is nooverridesmanifest field.
Complete Manifest Example
Section titled “Complete Manifest Example”{ "version": "1.2.0", "protocol": "1", "platform": ["linux"], "author": "suctl", "license": "AGPL-3.0", "description": "Manages nginx virtual hosts, SSL certificates, and site configuration", "entrypoint": "suctl-mod-nginx",
"requires": { "binaries": ["nginx"], "paths": ["/etc/nginx/sites-available", "/etc/nginx/sites-enabled"], "sockets": [], "permissions": [], "capabilities": [], "config": [] },
"capabilities": [ { "name": "nginx.domain.list", "description": "List all configured virtual hosts", "async": false, "params": [] }, { "name": "nginx.domain.create", "description": "Create a new nginx virtual host configuration", "async": false, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Primary domain name" }, { "name": "root_path", "type": "string", "required": true, "description": "Document root path" }, { "name": "template", "type": "string", "required": false, "default": "default", "description": "Config template name" } ] }, { "name": "nginx.domain.delete", "description": "Remove a virtual host configuration", "async": false, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Domain name to remove" } ] }, { "name": "nginx.domain.suspend", "description": "Suspend a domain by disabling its site configuration", "async": false, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Domain to suspend" } ] }, { "name": "nginx.domain.unsuspend", "description": "Re-enable a suspended domain", "async": false, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Domain to unsuspend" } ] }, { "name": "nginx.domain.verify", "description": "Verify a domain is serving correctly after configuration changes", "async": false, "params": [ { "name": "domain", "type": "string", "required": true, "description": "Domain to verify" } ] }, { "name": "nginx.reload", "description": "Reload nginx configuration without dropping connections", "async": false, "params": [] }, { "name": "nginx.provision", "description": "Provision a new domain with nginx configuration and SSL certificate", "async": true, "params": [ {"name": "domain", "type": "string", "required": true, "description": "Domain name"}, {"name": "root_path", "type": "string", "required": true, "description": "Document root path"} ] }, { "name": "nginx.domain.survey", "description": "Render the nginx domains survey for the REPL", "async": false, "params": [] }, { "name": "nginx.domain.focus", "description": "Render the focus view for a selected domain", "async": false, "params": [ { "name": "subject", "type": "string", "required": true, "description": "Subject id from the survey response" } ] } ]}
// Surface configuration is declared in a separate surface.json file in the same directory.// It is not a field in manifest.json. See the surface schema for the surface.json schema.// The surface's two entry capabilities are declared above (for the "domain" surface):// nginx.domain.survey — survey entry point// nginx.domain.focus — focus entry point