Skip to main content
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:
  1. Built-in modules, shipped as workspace packages.
  2. 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

MethodPathModeNotes
GET/modulesFilterable by query, type, isBuiltin, enabled
GET/modules/:moduleIdSingle module
POST/modulesDirectInstall from a .tar.zst URL, stores source = ""
POST/modules/installRegistryResolve registry metadata URL, stores source
PATCH/modules/:moduleIdDirectUpdate from a direct URL, clears source
POST/modules/:moduleId/updateRegistryRe-resolve stored registry source, re-download
POST/modules/:moduleId/enabledToggle { enabled: boolean }
POST/modules/reloadRe-scan custom modules from disk
GET/modules/:moduleId/config/schemaModule configuration schema
GET/modules/:moduleId/configCurrent module configuration
PATCH/modules/:moduleId/configPatch module configuration
GET/modules/:moduleId/secrets/schemaModule secrets schema
PATCH/modules/:moduleId/secretsPatch encrypted module secrets
DELETE/modules/:moduleIdRemove 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