Module Protocol
This document defines the wire protocol between suctl core and module binaries. Every module speaks this protocol. Core speaks nothing else to modules. This document is the complete and authoritative specification — module authors implement from here.
Design Principles
Section titled “Design Principles”Reality is the source of truth. The protocol does not store state. It does not maintain sessions. It does not cache results. Each exchange is complete in itself — a question and an honest answer about the current state of the system.
Dead simple. One wire per module. One envelope format. No heartbeat, no reconnection logic. The inherited socketpair stays open for the process lifetime and carries both directions. The overhead of a Unix socket for local IPC is negligible.
Language agnostic. The protocol is raw JSON over a Unix domain socket. Any language with a standard socket library and a JSON parser can implement a module. Go, Python, TypeScript, Rust, Ruby — all valid. No code generation. No protobuf schema. No language-specific tooling required.
Pure verbs. Protocol commands are verbs. The subject — the callable name or job token — is always a parameter. The protocol never encodes the subject in the command name.
The protocol is versioned at the protocol level — not per callable. A module declares which protocol version it speaks. Core speaks to modules at a known protocol version or rejects them.
Transport
Section titled “Transport”Socket type: Unix domain socket (AF_UNIX, SOCK_STREAM). The module’s wire is an inherited socketpair — address-less, with no filesystem path, ownership, or mode.
Lifecycle: Core creates a private bidirectional socketpair before starting the module process and passes the open file descriptor via SUCTL_BROKER_FD (fd 3). Possession of this wire is the module’s identity. The module never calls bind() or listen(). See core architecture.
Persistent wire. The inherited socketpair is kept open for the lifetime of the module process. Both directions (core→module and module→core) flow over the same fd; there is no per-exchange connect/disconnect.
A module may create or attach to other sockets for its own purposes (e.g. wiring into an external service it manages) — those sockets are the module’s business, outside the suctl boundary, and never carried in the manifest.
Envelope Format
Section titled “Envelope Format”All envelopes are JSON, newline-delimited. Each envelope is one line of JSON followed by \n.
Request envelope:
{"v": "1", "id": "9f1c…", "ts_sent": "2026-06-03T10:00:00+00:00", "cmd": "verb", "params": {}}Response envelope — success:
{"v": "1", "id": "9f1c…", "ts_sent": "2026-06-03T10:00:00+00:00", "status": "ok", "result": {}}Response envelope — error:
{"v": "1", "id": "9f1c…", "ts_sent": "2026-06-03T10:00:00+00:00", "status": "error", "error": {"code": "ERROR_CODE", "message": "human readable", "detail": {}}}id and ts_sent are present on every request and response — the canonical shapes above are authoritative. The per-command examples below omit them for brevity and show only the fields that differ per verb.
job_token is echoed in invoke responses, and at the envelope level in job_status responses — see the field table and per-command examples below.
Envelope fields
Section titled “Envelope fields”Request:
| Field | Type | Required | Description |
|---|---|---|---|
v | string | yes | Protocol version. "1" for current protocol. |
id | string | yes | Per-exchange UUID (v4). Set by the sender on every request; echoed unchanged in the response. A retry reuses the same id so the receiver can dedup and order by ts_received. |
ts_sent | string | yes | Origin time the sender stamped, RFC3339 with explicit offset (e.g. 2026-06-03T10:00:00+00:00). The sender’s claim — pair it with the receiver’s ts_received to measure latency and detect clock drift. |
cmd | string | yes | Command verb. See commands below. |
job_token | string | invoke, job_status, job_update | UUID generated by core for every invoke request and echoed by the module. Carried at the envelope level on job_status and job_update requests too (the job being polled or updated). Not present in handshake or health requests. Async callables use it as a polling handle for job_status. |
params | object | yes | Command parameters. Empty object {} if none. Never null. |
Response:
| Field | Type | Required | Description |
|---|---|---|---|
v | string | yes | Protocol version. Module echoes the version from the request. |
id | string | yes | Echoes the request’s per-exchange id unchanged, for correlation. |
ts_sent | string | yes | The responder’s origin time, RFC3339 with explicit offset. |
status | string | yes | "ok" or "error" |
job_token | string | invoke, job_status | Echoed unchanged from the request in invoke responses; job_status responses also carry the polled token at the envelope level (it additionally appears inside result). Absent in handshake and health responses. |
result | object | if status ok | Command result. Shape defined per command. |
error | object | if status error | Error detail. See error object below. |
ts_received is not a wire field. Each receiver stamps it locally at ingest, against its own clock — the single ordering authority and the trusted audit anchor. It is what the receiver vouches for (versus ts_sent, the sender’s claim). A job’s queued_at/finished_at are read directly from the ts_received of its first and terminal stored messages, and its started_at is the moment core promoted it from the queue (the Started stamp) — not a separate projection. It never travels in an envelope.
Error object:
| Field | Type | Required | Description |
|---|---|---|---|
code | string | yes | Machine-readable error code. See error codes below. |
message | string | yes | Human-readable description. Operator-facing. |
detail | object | no | Additional context. Shape is command-specific. |
Callables: Capabilities
Section titled “Callables: Capabilities”invoke operates on any capability. Atomic and compound operations are both declared as capabilities in the manifest. From the protocol’s perspective there is no distinction: both have a name, params, and a result. When this document says “capability name” it means any fully-qualified capability—e.g., nginx.reload or nginx.provision.
Commands
Section titled “Commands”Five command verbs. Four are sent by core to every module, and every module must answer them — handshake, health, invoke, job_status. The fifth, job_update, travels the other way: a module sends it back to core over its inherited broker wire to push job state.
handshake
Section titled “handshake”Sent by core immediately after connecting to a module socket, before any other command. The module returns its manifest. Core validates the manifest and determines whether to activate the module.
Request:
{"v": "1", "cmd": "handshake", "params": {}}Response — success:
{ "v": "1", "status": "ok", "result": { "manifest": { "...complete manifest per the manifest schema..." } }}Response — error:
{ "v": "1", "status": "error", "error": { "code": "HANDSHAKE_FAILED", "message": "Module could not produce manifest", "detail": { "reason": "..." } }}If handshake fails or returns an invalid manifest, core logs the failure and skips the module. Not fatal — other modules continue loading.
health
Section titled “health”Sent by core periodically to verify the module process is alive and responsive. The health check rides the persistent inherited wire.
Request:
{"v": "1", "cmd": "health", "params": {}}Response:
{ "v": "1", "status": "ok", "result": { "status": "healthy", "uptime_seconds": 3612 }}If the connection fails, core marks the module as unavailable. The REPL shows the module as unavailable until the next successful health check. No cached last-known-healthy status is shown.
invoke
Section titled “invoke”Invokes any capability declared in the module’s manifest. Core generates a job_token for every invocation and includes it in the request. Sync capabilities return the result immediately in the same response. Async capabilities acknowledge receipt immediately; the caller polls job_status using the token until done or failed.
Request:
{ "v": "1", "cmd": "invoke", "job_token": "550e8400-e29b-41d4-a716-446655440000", "params": { "name": "nginx.domain.create", "args": { "domain": "example.com", "root_path": "/var/www/example" } }}| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Full capability name. Must match a capability declared in manifest. |
args | object | yes | Arguments matching the capability’s declared params. Empty object if no params. |
Response — synchronous (async: false):
{ "v": "1", "status": "ok", "job_token": "550e8400-e29b-41d4-a716-446655440000", "result": { "name": "nginx.domain.create", "output": { "config_path": "/etc/nginx/sites-available/example.com" } }}Response — asynchronous (async: true):
Module acknowledges receipt immediately. Core tracks the job by job_token. The module is responsible for pushing progress and final status updates to core via the job_update verb on its inherited broker wire.
{ "v": "1", "status": "ok", "job_token": "550e8400-e29b-41d4-a716-446655440000", "result": { "name": "certbot.cert.provision", "accepted": true }}Response — unknown callable:
{ "v": "1", "status": "error", "job_token": "550e8400-e29b-41d4-a716-446655440000", "error": { "code": "UNKNOWN_CALLABLE", "message": "nginx.domain.unknown is not registered by this module" }}Response — execution failure:
{ "v": "1", "status": "error", "job_token": "550e8400-e29b-41d4-a716-446655440000", "error": { "code": "CALLABLE_FAILED", "message": "nginx reload failed — configuration test returned errors", "detail": { "returncode": 1, "stderr_tail": "nginx: [emerg] unknown directive..." } }}job_status
Section titled “job_status”Sent by core to poll the status of an async job. While job_update is the preferred way for modules to push updates, core may use job_status for re-syncing or if an update was missed.
Request:
{ "v": "1", "cmd": "job_status", "job_token": "550e8400-e29b-41d4-a716-446655440000", "params": {}}Response — running:
{ "v": "1", "status": "ok", "result": { "job_token": "550e8400-e29b-41d4-a716-446655440000", "state": "running", "started_at": "2026-05-17T14:00:00Z", "output": null }}Response — done:
{ "v": "1", "status": "ok", "result": { "job_token": "550e8400-e29b-41d4-a716-446655440000", "state": "done", "started_at": "2026-05-17T14:00:00Z", "finished_at": "2026-05-17T14:01:12Z", "output": { "returncode": 0, "duration_seconds": 72, "stderr_tail": "..." } }}Response — failed:
{ "v": "1", "status": "ok", "result": { "job_token": "550e8400-e29b-41d4-a716-446655440000", "state": "failed", "started_at": "2026-05-17T14:00:00Z", "finished_at": "2026-05-17T14:00:45Z", "error": { "code": "CALLABLE_FAILED", "message": "certbot failed: DNS challenge not resolved within timeout", "detail": { "returncode": 1, "stderr_tail": "..." } } }}Job states: queued → running → done | failed
A job is not a stored record — it is the set of messages in core’s messages store that share a job_token. Its state is read, never folded: a done/failed terminal message in the store is the job’s terminal state; absent one, the job is running once core has promoted it (stamped its Started fact), otherwise queued (a job waiting behind another job’s footprint stays queued until that footprint frees and the queue manager promotes it). queued/running are core-managed; a module’s job_update only ever carries running, done, or failed. A queued job has no started_at yet — core stamps it on promotion.
The messages store is in-memory and ephemeral. Job tokens do not survive a module process restart. If a module restarts while a job is running, the job is lost. Core treats a failed job_status connection as job state unknown and reports this honestly to the operator.
Retention: the messages store keeps a bounded trail — the oldest messages of terminal jobs age out first once the trail exceeds its cap. Messages belonging to a non-terminal (queued/running) job are never evicted.
job_update (Module to Core)
Section titled “job_update (Module to Core)”Sent by a module to core to update a job’s progress or state. Core owns the async job lifecycle (D61); this push is the module’s optional way of reporting progress before the handler returns. The job_token must match an active job in core’s messages store (a non-terminal exchange bearing that token); possession of a valid, active token is the sole proof of authorization.
Request:
{ "v": "1", "cmd": "job_update", "job_token": "550e8400-e29b-41d4-a716-446655440000", "params": { "state": "running", "message": "Downloading certificate chain...", "progress": 45 }}| Field | Type | Required | Description |
|---|---|---|---|
state | string | no | New job state: running, done, failed. |
message | string | no | Human-readable progress message to append to the job’s log. |
output | object | no | Partial or final output data. Merged into existing output if present. |
error | object | if state failed | Error detail if the job has failed. |
progress | integer | no | Completion percentage (0-100). |
Response:
{ "v": "1", "status": "ok"}Core validates the token against the messages store. If no non-terminal exchange carries the token (unknown or evicted), core returns JOB_NOT_FOUND. Core writes the terminal state (done, failed) itself from the capability handler’s synchronous return; the module does not need to push terminal state (D61).
Standard DTOs
Section titled “Standard DTOs”Standard data transfer objects used by core capabilities. These shapes are normative for any client (REPL, daemon, CLI).
system.module.inventory — InventoryResponse
Section titled “system.module.inventory — InventoryResponse”The inventory of all discovered modules.
| Field | Type | Description |
|---|---|---|
entries | array | List of InventoryEntry objects (operator-managed modules only). |
active_count | integer | Number of entries in the active state — precomputed so faces render headers without iterating entries. |
ready_count | integer | Number of entries in the ready state — precomputed alongside active_count. |
InventoryEntry fields:
| Field | Type | Description |
|---|---|---|
shortname | string | Module short name (e.g. "nginx", "certbot") |
state | string | Current state (ready, active, unavailable, missing, failed) |
reason | string | Human-readable explanation when state is unavailable, missing, or failed; omitted otherwise |
description | string | One-sentence description from manifest |
surface_config | object | The module’s surface.json content as raw JSON (omitted when absent) |
Error Codes
Section titled “Error Codes”| Code | Meaning |
|---|---|
UNKNOWN_COMMAND | Command verb not recognized by this module |
UNKNOWN_CALLABLE | Callable name not in this module’s manifest |
INVALID_PARAMS | Missing required parameter or wrong type |
CALLABLE_FAILED | Callable executed but returned non-zero or error state |
HANDSHAKE_FAILED | Module could not produce a valid manifest |
JOB_NOT_FOUND | Job token is unknown or has been evicted |
CAPABILITY_NOT_ACTIVE | Broker: target capability is not in the active surface |
CAPABILITY_NOT_DECLARED | Broker: cross-module hop to a capability not listed in the calling module’s requires.capabilities (D60) |
CONFIRMATION_REQUIRED | system.module.activate: the target also requires activating other inert providers. detail carries the sdk/protocol/cascade.go payload. |
INTERNAL_ERROR | Unexpected module-internal failure |
Go SDK: All codes above are exported as
protocol.Code*constants insdk/protocol(CodeUnknownCommand,CodeCallableFailed,CodeInternalError, etc.). Always use these constants instead of bare strings in Go module handlers.
Broker Wire
Section titled “Broker Wire”Core provides a private bidirectional wire for every core-managed module. This same wire is used for both core-to-module invokes and module-to-core calls (including cross-module capability calls).
Availability: Present from the moment the module is launched (inherited as SUCTL_BROKER_FD).
A module executing a capability that requires another module’s capability sends an invoke envelope back to core over its inherited wire. The envelope format and command semantics are identical to the core-to-module protocol — same invoke command, same job_token, same response shape. Core (the orchestrator) validates the target capability is active, identifies the caller by the wire it arrived on, authorizes the call (D60), routes to the target module’s wire, and returns the response. The calling module never knows the target module’s location or platform.
Request — identical to invoke:
{ "v": "1", "cmd": "invoke", "job_token": "550e8400-e29b-41d4-a716-446655440000", "params": { "name": "certbot.ssl.provision", "args": { "domain": "example.com", "config_path": "/etc/nginx/sites-available/example.com" } }}Error — capability not in active surface:
{ "v": "1", "status": "error", "job_token": "550e8400-e29b-41d4-a716-446655440000", "error": { "code": "CAPABILITY_NOT_ACTIVE", "message": "certbot.ssl.provision is not in the active capability surface" }}The broker checks two things at call time. First, the capability name must be in the active surface right now (else CAPABILITY_NOT_ACTIVE, above). Second — for a cross-module hop only (a request arriving on another module’s wire, attributed to that module) — the target capability must appear in the calling module’s requires.capabilities (D60); an undeclared hop is rejected with CAPABILITY_NOT_DECLARED. The originating face (no module identity) and a module calling its own capabilities are exempt. The activation-time requirement check decides a module can run; this runtime check decides which capabilities it may reach.
Error — undeclared cross-module call (D60):
{ "v": "1", "status": "error", "job_token": "550e8400-e29b-41d4-a716-446655440000", "error": { "code": "CAPABILITY_NOT_DECLARED", "message": "module \"nginx\" has not declared \"certbot.cert.remove\" in requires.capabilities" }}Go SDK — broker client
Section titled “Go SDK — broker client”Go module authors use sdk/brokerclient instead of hand-rolling the broker envelope. The helper handles job_token generation and propagation, JSON encoding/decoding, and error unpacking:
import "github.com/solutionsunity/suctl/suctl/sdk/brokerclient"
// The zero-value client targets the inherited SUCTL_BROKER_FD with default timeouts.resp, err := brokerclient.InvokeAndWait("certbot.cert.provision", map[string]interface{}{ "domain": "example.com", "config_path": "/etc/nginx/sites-available/example.com",})InvokeAndWait returns (*protocol.InvokeResponse, error) whose Output carries the result payload; failures come back as a wrapped *protocol.ErrorDetail. A cross-module call returns its terminal result inline — it executes synchronously, re-entrant within the originator’s already-running footprint — so there is no module-side polling. The lower-level Invoke returns the raw *protocol.Response. The context-aware InvokeContext / InvokeAndWaitContext propagate the originating job_token carried on the handler’s ctx, so a sub-call re-enters the originator’s single job bucket instead of minting a new one; a handler should pass the ctx it received. The package-level brokerclient.Invoke / InvokeAndWait / InvokeContext / InvokeAndWaitContext wrappers use a zero-value client. The wire format above remains the normative specification; the SDK helper is the recommended implementation for Go modules.
Module Process Lifecycle
Section titled “Module Process Lifecycle”This section covers what the module process itself must do. For the complete ordered sequence of what suctl does — startup phases, requirements checks, hook callout points, operator-triggered activation and deactivation, runtime monitoring, upgrade, and shutdown — see module lifecycle.
Startup (module process perspective)
Section titled “Startup (module process perspective)”- Core reads
manifest.jsonfrom disk for each discovered module directory - Core runs requirements checks and fires the declared
pre-activatehook - Core creates a private bidirectional socketpair and passes one end to the child via
SUCTL_BROKER_FD - Core launches the module process using the entrypoint declared in
manifest.json - Core sends
handshake; module returns manifest; core validates its protocol matchesmanifest.jsonon disk (D55) - Core activates or marks unavailable based on manifest validation
Shutdown (module process perspective)
Section titled “Shutdown (module process perspective)”Modules are always-on (D57): core sends SIGTERM only at core shutdown or operator deactivation, never because the module is idle.
- Module receives SIGTERM
- Module completes any in-progress synchronous invocations
- Module marks all running async jobs as failed with reason
"module_shutdown" - Module exits
Crash recovery
Section titled “Crash recovery”If a module process dies unexpectedly:
- Core detects process exit
- Core fires
on-crashhook if declared - Core restarts the module immediately (always-on, D57)
- More than 3 restarts in 60 seconds → core marks module FAILED, no further restarts
suctl-mod-odoo
Section titled “suctl-mod-odoo”suctl-mod-odoo is a conforming, core-managed module. It speaks full protocol v1 — handshake, health, invoke — over its inherited SUCTL_BROKER_FD wire like every other module. The socket it creates to talk to the Odoo service is its own business — wired into Odoo’s systemd unit by the module, outside the suctl boundary, and never visible to core. See module lifecycle → External service sockets and modules/suctl-mod-odoo/ for the implementation.
Protocol Version History
Section titled “Protocol Version History”| Version | Changes |
|---|---|
1 | Initial protocol. Commands: handshake, health, invoke, job_status. |
Protocol versions are integers. When the protocol changes in a backward-incompatible way, the version increments. Core maintains support for one prior version during a transition period. After the transition, older versions are rejected.