Skip to content

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.


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.


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.

  • 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"
  • 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.
  • 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"]
  • 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"
  • 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"
  • 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"
  • 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.

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.

  • 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"]
  • 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"]
  • 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"]
  • 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"]
  • 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"]
  • 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:
FieldTypeRequiredDescription
keystringyesConfig key name, namespaced: {module-short-name}.{key}
requiredbooleanyesIf true, module will not activate without this key
secretbooleannoIf true, value is masked in logs and REPL output
defaultstringnoDefault value if not set. Only valid when required: false
descriptionstringyesOperator-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"
}
]

Array of capability declarations. Each capability is an atomic operation this module provides.

FieldTypeRequiredDescription
namestringyesFull namespaced name: {module-short-name}.{action} or {module-short-name}.{domain}.{action}. See naming below.
descriptionstringyesOne sentence, operator-facing. What this capability does.
asyncbooleannoIf true, core handles as async (returns job token, module pushes updates). Default: false (synchronous).
paramsarraynoParameter declarations. See params object below.

Format: {module-short-name}.{action} or {module-short-name}.{domain}.{action}

  • module-short-name: the module name without suctl-mod- prefix. nginx for suctl-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 subject
nginx.domain.list ← three parts: list targets the domain subject
nginx.domain.create ← three parts
nginx.ssl.renew ← three parts: renew targets the ssl subject
certbot.ssl.provision ← three parts
odoo.module.install ← three parts
odoo.db.create ← three parts
system.service.restart ← three parts
system.disk.usage ← three parts
network.dns.check ← three parts
network.ping ← two parts: ping belongs directly to network
cloudflare.proxy.toggle ← three parts
FieldTypeRequiredDescription
namestringyesParameter name
typestringyesstring, boolean, integer, array
requiredbooleanyesIf false, parameter is optional
descriptionstringyesOperator-facing description
defaultanynoDefault value. Only valid when required: false
"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 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.

FieldTypeRequiredDescription
execstringone of exec/capability requiredPath to a script relative to the module directory. suctl runs it with the module directory as working directory.
capabilitystringone of exec/capability requiredCapability name declared by this module. Called via the module process. Only valid for events where the module process is already running.
timeout_secondsintegernoFor blocking hooks: how long suctl waits before proceeding. Default: 30.
EventBlockingcapability form allowed
on-requirement-missingYesNo — process not running
pre-activateYesNo — process not running
post-activateYesYes
on-activate-failNoNo — process may not be running
pre-deactivateYesYes
post-deactivateNoNo — process has exited
on-health-failNoYes
on-health-recoverNoYes
on-crashNoNo — process has exited

Full event semantics, execution contract, and patterns: module lifecycle.

"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_path field does not exist. Every module is core-managed: core spawns it and passes an inherited socketpair via SUCTL_BROKER_FD (fd 3 on Unix; SUCTL_BROKER_FD_R / SUCTL_BROKER_FD_W on 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.


overrides field does not exist. A module that wants different behavior declares its own capability with its own name. There is no overrides manifest field.


{
"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