Cyrnel coordinates two kinds of modules:
Both implement the base Module interface from @cyrnel/sdk.
The Module Registry
Modules are registered with the ModuleService at startup. There are two
sources:
-
Built-in modules, shipped as workspace packages.
-
Custom modules, loaded from
$CYRNEL_DATA_DIR/modules/<name>/. Each
directory must contain a module.json:
{
"id": "my-adapter",
"type": "adapter",
"name": "My Adapter",
"description": "this is my my adapter",
"main": "./index.js",
"configSchema": { ... },
"secretsSchema": { ... },
}
main is an ES module exporting instantiate(): AdapterModule or
EnvironmentModule. See
Writing a custom module.
The registry is reflected to the modules table. Three things can happen
during reconciliation:
- Insert: A registered module that isn’t in the DB yet. Default
enabled: true, missing: false.
- Missing: A row in the DB whose module is no longer registered (e.g.
the custom folder was removed). Set
missing: true, preserving enabled.
The row stays so its config/enablement is preserved if the module reappears.
- Restore: A missing row whose module is registered again. Clear
missing: false. Services recalculate effectivelyEnabled.
POST /modules/reload re-scans $CYRNEL_DATA_DIR/modules/ and reruns
reconciliation. Use it after dropping a new custom module into the directory.
Install
Modules can be installed in two modes:
Direct install
POST /modules
{ "url": "https://example.com/my-module.tar.zst" }
The url is a direct download URL to the .tar.zst archive itself. It is
ephemeral — passed by the client, never stored. The downloaded archive is
extracted, validated for a module.json manifest, registered in memory, and
persisted to the database with source = "".
Registry install
POST /modules/install
{ "source": "https://registry.example.com/modules/my-module" }
The source is a registry URL pointing to a metadata API endpoint. The server
fetches source, expecting a JSON response:
interface ModuleRegistryResponse {
downloadUrl: string; // URL to the .tar.zst archive
hash?: string; // SHA-256 of the .tar.zst binary (skip + integrity)
}
If the response includes a hash, the server verifies the downloaded archive
content matches it. The source URL is persisted in the database so the module
can be re-resolved later for updates. The module starts disabled.
Update
Modules can be updated in two modes, matching the install mode.
Direct update
PATCH /modules/:moduleId
{ "url": "https://example.com/my-module-v2.tar.zst" }
Downloads the new archive from the direct url, backs up the existing module
directory, extracts the new archive, registers the updated factory, and
updates the database row (including clearing source to ""). If the update
fails partway, the backup is restored.
After the module is installed, cyrnel regenerates every service that
targets this adapter: for each non-missing service, the stored definition
content is re-fed through the new adapter’s generateDefinition. Services
that succeed get fresh tools immediately. Services that fail are marked
stale and cannot be invoked until they are manually synced
(via PATCH /services/:id or POST /services/:id/update).
Registry update
POST /modules/:moduleId/update
Re-resolves the stored source URL by fetching the registry metadata again.
If the registry returns a hash that matches the stored hash, the update is
skipped and the response is { updated: false }. Otherwise the new archive
is downloaded, verified, extracted, and registered. Direct-installed modules
(source = "") return 409 — use PATCH instead.
Same service regeneration behavior as the direct update: every non-missing
service is re-parsed through the new adapter. Failures are marked stale.
The source column semantics:
source = "" — direct-installed item (no registry reference).
source = <url> — registry-installed item (stored registry URL).
Activation
Activation = “this module is now ready to take work”. It is per-module-type:
- Adapters activate eagerly. On startup cyrnel activates every adapter row
with
enabled = true, missing = false, runs setup({}), then hydrates
every enabled service that targets it.
- Environments activate one-at-a-time. Only the environment whose row is
enabled gets activated. Enabling a different environment disables the
previously-enabled one in the DB and swaps the active reference.
Draining environments
When the active environment is replaced or disabled, it doesn’t disappear,
instead, it becomes draining. New executions go to the newly-active
environment (or fail with 503 if none). The draining environment keeps
running its in-flight executions; once all of them finish, cyrnel tears it
down. This means a disable request with returns immediately, but the actual
teardown may happen seconds later.
Configuration and Secrets
Modules can declare configSchema and secretsSchema in their manifest.
Cyrnel stores module configuration as plaintext JSON and module secrets as
AES-256-GCM ciphertext using CYRNEL_SECRETS_KEY.
Configuration is readable and patchable:
GET /modules/:moduleId/config/schema returns the module’s configuration
JSON Schema.
GET /modules/:moduleId/config returns the currently stored configuration.
PATCH /modules/:moduleId/config accepts a JSON Patch array, validates the
resulting object against configSchema, persists it, and reloads the active
module instance when required.
Secrets are write-only:
GET /modules/:moduleId/secrets/schema returns the module’s secrets JSON
Schema.
PATCH /modules/:moduleId/secrets accepts a JSON Patch array, applies it to
the decrypted secret document, validates against secretsSchema, then
re-encrypts and persists the result.
There is no endpoint that returns plaintext module secrets. Use configuration
for non-sensitive values only; put credentials and tokens in secrets.
Module API
| Method | Path | Mode | Notes |
|---|
GET | /modules | — | Filterable by query, type, isBuiltin, enabled |
GET | /modules/:moduleId | — | Single module |
POST | /modules | Direct | Install from a .tar.zst URL, stores source = "" |
POST | /modules/install | Registry | Resolve registry metadata URL, stores source |
PATCH | /modules/:moduleId | Direct | Update from a direct URL, clears source |
POST | /modules/:moduleId/update | Registry | Re-resolve stored registry source, re-download |
POST | /modules/:moduleId/enabled | — | Toggle { enabled: boolean } |
POST | /modules/reload | — | Re-scan custom modules from disk |
GET | /modules/:moduleId/config/schema | — | Module configuration schema |
GET | /modules/:moduleId/config | — | Current module configuration |
PATCH | /modules/:moduleId/config | — | Patch module configuration |
GET | /modules/:moduleId/secrets/schema | — | Module secrets schema |
PATCH | /modules/:moduleId/secrets | — | Patch encrypted module secrets |
DELETE | /modules/:moduleId | — | Remove module and its filesystem directory |
Enabling a missing module returns 409. Direct-installed items (source = "")
cannot use POST /modules/:moduleId/update — they return 409.
A module record:
interface ModuleManifestRecord {
id: string;
name: string;
description: string;
type: "adapter" | "environment";
hash: string;
source: string;
isBuiltin: boolean;
enabled: boolean;
missing: boolean;
configSchema: JSONSchema;
secretsSchema: JSONSchema;
}
Picking the Right Module Type
- If you need to let user code reach a new kind of end service, write an
adapter.
- If you need to change how code is executed (different language,
different sandbox, different permission model), write an environment.
Last modified on June 24, 2026