- Adapter modules translate environment invocations into calls to end services.
- Environment modules execute the code clients submit.
Module interface from @cyrnel/sdk.
The Module Registry
Modules are registered with theModuleService 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 amodule.json:versionis a required semver string.enginesis optional, but when present itscyrnelfield is required: a semver range the Cyrnel core version (CYRNEL_CORE_VERSION) must satisfy, or the module is rejected with a 400 error. Omittingenginesentirely means “no core version constraint”.compatibilityis optional and only meaningful foradaptermodules. Each entry declares an<identifier>and a semverversionrange that the adapter can consume (see Definition kinds & adapter compatibility). When a service definition carries a matching kind, the adapter is offered as an install target for it.mainis an ES module exportinginstantiate(): AdapterModuleorEnvironmentModule. See Writing a custom module.
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, preservingenabled. 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 recalculateeffectivelyEnabled.
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 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 is a registry URL pointing to a metadata API endpoint. The server
fetches source, expecting a JSON response with a versioned descriptor:
latestVersion entry by default, or a specific
version/range when version is provided in the request body:
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.
If a version entry declares an icon, the server downloads the image bytes
through the same SSRF-guarded egress path as archives, verifies the SHA-256
hash, and only accepts png/webp content sniffed from the file magic.
The icon is stored server-side and served from
GET /modules/:moduleId/icon with an ETag for caching. Icon failures are
non-fatal: the module installs without an icon. Direct installs and direct
updates (PATCH) never carry an icon. On registry updates the icon is
re-fetched when its hash changes and cleared when the registry no longer
declares one; a failed re-fetch is non-fatal and preserves the previously
stored icon.
Recommended icon properties: a square 256×256 px image (png or webp, with
transparency if needed) no larger than 256 KB. Icons are displayed at 40×40 px
in the web UI.
Update
Modules can be updated in two modes, matching the install mode.Direct update
PATCH /modules/:moduleId
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
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, runssetup({}), 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 with503 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 declareconfigSchema and secretsSchema in their code export,
not in module.json. Cyrnel stores module configuration as plaintext JSON
and module secrets as
AES-256-GCM ciphertext using CYRNEL_SECRETS_KEY. When a record is
read that was encrypted with a previous key (during rotation), it is
silently re-encrypted with the current primary key and persisted.
Configuration is readable and patchable:
GET /modules/:moduleId/config/schemareturns the module’s configuration JSON Schema.GET /modules/:moduleId/configreturns{ config, outdated }: the stored configuration projected to keys defined by the schema, plus RFC 6901 pointers of stored values that no longer match the schema.PATCH /modules/:moduleId/configaccepts a JSON Patch array, persists the result, and reloads the active module instance when required. Stored values that are no longer defined by the schema are preserved; adding new schema-disallowed keys is rejected;removeoperations on missing paths are ignored.
GET /modules/:moduleId/secrets/schemareturns the module’s secrets JSON Schema.PATCH /modules/:moduleId/secretsaccepts a JSON Patch array, applies it to the decrypted secret document, re-encrypts and persists the result. Stored values that are no longer defined by the schema are preserved; adding new schema-disallowed keys is rejected.
Module API
Enabling a missing module returns
409. Direct-installed items (source = "")
cannot use POST /modules/:moduleId/update — they return 409.
GET /modules returns the paginated envelope { items, nextCursor, hasMore }; see Pagination for the cursor rules, limit semantics, and error codes.
A module record:
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.