How to write a SoulModule
SoulModule is a plugin that implements a single Destiny step: it brings a resource on the host to the desired state. The binary is named soul-mod-<name> and is launched by the soul agent. This section is about how to write such a module; the general plugin model is in the extending overview.
First check whether what you need is already in the core module catalog: packages, files, services, users, cron, mounts, firewall, git, archives, and sysctl are already covered by built-in modules. A SoulModule plugin is for integrations that are not in core — for example a specific database, a reverse proxy, or a container runtime.
The contract: what a module implements
Section titled “The contract: what a module implements”A module implements an interface from the SDK. Two key methods:
Validate— checks the step’s input parameters (no side effects). Invalid input is rejected before applying. The same contract givessoul-lintits static check via the manifest.Apply— brings the resource to the desired state and reports what changed.
This is the same interface the built-in core modules implement — a plugin and a core module are indistinguishable to the agent.
Idempotency is mandatory
Section titled “Idempotency is mandatory”Apply must be idempotent: first read the resource’s current state, and change it only if it differs from the desired state. The step’s result is a changed flag:
changed = true— the resource’s state was changed by this run.changed = false— the resource was already in the desired state, nothing was done.
This flag is not cosmetic: dependencies between steps are built on it (for example onchanges: — “restart the service only if the config changed”). A module that always returns changed = true breaks this mechanism and triggers needless restarts. More on idempotency and changed is in the Destiny section.
State semantics follow the resource naturally: for persistent resources it is present / absent, for processes and containers running / stopped / absent. There is no need to force present/absent on everything — pick states that reflect the nature of the resource.
The manifest
Section titled “The manifest”Next to the binary is a static manifest.yaml. It describes the plugin so that soul-lint can validate Destiny without running the plugin:
kind: soul_module # plugin typeprotocol_version: 1 # plugin protocol version (compat flag, not the module version)namespace: acme # author's namespacename: pgrole # module namerequired_capabilities: [network_outbound] # what it needs from the hostside_effects: # which resources it touches - { service: postgresql }spec: states: present: # supported states and their parameters input: name: { type: string, required: true } password: { type: string, secret: true } absent: input: name: { type: string, required: true }kind: soul_module— the plugin-type discriminator.protocol_version— the version of the plugin protocol, a compat flag. It is not the version of your module (the module version is the git ref of the release). The host supports a fixed set of protocol versions; on a mismatch the launch is rejected. You only bump this number when the protocol format changes, not on every module release.required_capabilities— what the plugin needs from the host (for example outbound network, Vault access, running as root).soul-lintstatically checks that what is requested fits the host’s policy and fails Destiny before applying if it does not.side_effects— every resource the module touches (services, files, packages, ports, and so on). This is both an audit trail and conflict detection: if two modules in the same run claim the same resource, the host flags it. If a module touches a resource at runtime that is not declared inside_effects, the step is marked as a policy violation.spec.states— the supported states and the parameter schema for each.soul-lintuses it to check a Destiny step’s parameters.
The manifest and the module’s code must stay in sync — that is the author’s responsibility (a self-test helps: calling Apply with parameters outside the schema should return an argument-validation error).
Building and wiring it in
Section titled “Building and wiring it in”-
Generate the skeleton with
soul-lint plugin-init:Terminal window soul-lint plugin-init acme/pgrole --out ./soul-mod-pgrole --author "Acme Ops"This gives a ready project tree with a manifest and a handler stub — all that is left is to write
Apply. -
Implement
Validate/Applythrough the SDK interface and build the binarysoul-mod-<name>. Because the plugin is a separate process that talks over gRPC, the implementation language imposes no runtime dependency on the managed host: a self-contained binary arrives on the host. -
Check the manifest locally:
Terminal window soul-lint validate-manifest ./soul-mod-pgrole/manifest.yaml
How the agent picks up the module
Section titled “How the agent picks up the module”Plugins are registered with Keeper (source + git ref); Keeper resolves them into binaries and keeps them in an artifact cache. Delivery to the host and caching work like this:
- On the host, binaries and modules live in a local cache; the file name includes the SHA-256 of the content, so several versions can sit side by side and delivery happens only on an actual change.
- In push mode Keeper sends the host all registered modules in bulk (without analyzing the specific Destiny) and compares each one’s SHA-256 against what is already cached: a match skips the copy, a change pulls it down. The
soulbinary itself is updated by the same SHA-256 comparison mechanism. - In pull mode the module is delivered by an ordinary Destiny operation, placed in the local cache, and verified by fingerprint.
When executing a step, the agent launches the cached binary as a child process, performs the handshake, and calls Apply over gRPC. More on the host cache and delivery is in the Soul component.
What’s next
Section titled “What’s next”- Extending overview — the plugin model and the SDK.
- CloudDriver and SshProvider — server-side plugins.
- The core module catalog — reference examples of idempotent behavior.
- soul-lint — scaffolding and validation.