Skip to content

Project extensions

A project gives Realm its own code in files that a workflow names with extensions. A file can provide 3 kinds of thing: adapters, handlers and processors. This page gives what a file must export, what each kind must have, every refusal, which commands load the code and when, and what a run records about the code that ran it. Every message shown came from a run of realm against a project at /srv/shop.

The same 3 kinds can also be built from settings in realm.yaml. See Deployment manifest.

extensions: ../ext/a.mjs
extensions:
- ../ext/a.mjs
- ../ext/b.mjs

extensions is one path or a list of paths. Each path is taken from the workflow’s folder, and each file must be inside the workflow’s project. The field is described in Workflow file: top-level fields.

The file’s default export is an object with up to 3 keys. Each is a map from a name to a thing:

export default {
adapters: { crm: crmAdapter },
handlers: { order_total: orderTotal },
processors: {},
};
Key Each thing must have Type exported by @sensigo/realm A workflow uses it with
adapters The functions fetch, create and update. ServiceAdapter adapter: <name> in services.
handlers The function execute. StepHandler handler: <name> on a step.
processors The function process. Processor Nothing. No workflow field uses one.

Realm checks that those functions are there, and checks nothing else about the thing. It does not have to be made from a class.

realm workflow validate counts what the files gave:

Valid: flow v1 (1 step)
Extensions: ../ext/a.mjs, ../ext/b.mjs (adapters: 0, handlers: 2, processors: 0)

The name of a thing is its key in the map.

Case What happens
The thing has an id that differs from its key The key is used. [realm] extension handler registered as 'hello' (map key) but its instance id is 'x' (module '../ext/a.mjs'). The registration name is the map key.
An adapter is named filesystem It replaces the built-in one. [realm] extension adapter 'filesystem' from '../ext/a.mjs' overrides the built-in adapter 'filesystem'.
Two files give the same name Refused. Extension handler 'hello' is declared by both '../ext/a.mjs' and '../ext/b.mjs' — extension names must be unique across declared modules.
A file and realm.yaml give the same name Refused. See Deployment manifest.

Adapters, handlers and processors are named apart: an adapter and a handler may have the same name.

File Loaded as Recorded as
.mjs, or .js in an ES-module project An ES module. esm
.cjs A CommonJS module. module.exports is the object. cjs
.ts, .mts, .cts TypeScript, through the jiti package installed in the project. ts-jiti

Without jiti in the project, a TypeScript file is refused:

Error loading extensions: Module '../ext/e.ts' (/srv/shop/ext/e.ts) is TypeScript, but 'jiti' is not installed in your project. Install jiti in your project (npm install --save-dev jiti), or compile the module to JS and declare the compiled path.

Each message follows Error loading extensions:.

The file Message
Is not there Cannot resolve extension module '../ext/a.mjs' of workflow 'flow' (resolved: /srv/shop/ext/a.mjs): ENOENT: no such file or directory, lstat '/srv/shop/ext/a.mjs'
Is outside the project, or is a link to a file outside it Extension module '../../outside.mjs' resolves to '/srv/outside.mjs', which is OUTSIDE the workflow's trust root '/srv/shop'. Extension modules must live within the project containing the workflow …
Throws when it is loaded Failed to import module '../ext/a.mjs' (/srv/shop/ext/a.mjs): DATABASE_URL is not set
Has a syntax error Failed to import module '../ext/a.mjs' (/srv/shop/ext/a.mjs): Unexpected end of input
Imports a package that is not installed Failed to import module '../ext/a.mjs' (/srv/shop/ext/a.mjs): Cannot find package 'not-installed-pkg' imported from /srv/shop/ext/a.mjs
Has no default export Extension module '../ext/a.mjs' has no default export — export a declarative object: export default { adapters: { name: instance }, handlers: { ... }, processors: { ... } }
Exports a list or a function Extension module '../ext/a.mjs': default export must be a plain object ({ adapters?, handlers?, processors? }), got array.
Exports an object with register or getAdapter functions Extension module '../ext/a.mjs': default export looks like an ExtensionRegistry instance. Export a declarative object instead: …
Has a key other than the 3 Extension module '../ext/a.mjs': unknown key 'helpers' in default export — allowed keys are 'adapters', 'handlers', 'processors'.
Has a list where a map belongs Extension module '../ext/a.mjs': 'handlers' must be an object map of name → instance, got array.
Has an entry that is not an object Extension handler 'hello' in '../ext/a.mjs': expected an object instance, got null.
Has a handler without execute Extension handler 'hello' in '../ext/a.mjs': missing callable 'execute' — handlers must implement 'execute' per the @sensigo/realm StepHandler interface.
Has an adapter without one of its 3 functions Extension adapter 'crm' in '../ext/a.mjs': missing callable 'create' — adapters must implement 'fetch'/'create'/'update' per the @sensigo/realm ServiceAdapter interface.
Has a processor without process Extension processor 'clean' in '../ext/a.mjs': missing callable 'process' — processors must implement 'process' per the @sensigo/realm Processor interface.

A value of extensions that is not a path or a list of paths is refused by the workflow loader:

Invalid workflow: 'extensions' must be a non-empty module path or a non-empty array of module paths (e.g. extensions: ./dist/registry.js)

See Handlers.

interface ServiceAdapter {
readonly id: string;
readonly config_schema?: Record<string, unknown>;
readonly defaultRetryAfterSeconds?: number;
fetch(operation, params, config, signal?): Promise<ServiceResponse>;
create(operation, params, config, signal?): Promise<ServiceResponse>;
update(operation, params, config, signal?): Promise<ServiceResponse>;
delete?(operation, params, config, signal?): Promise<ServiceResponse>;
}
interface ServiceResponse {
status: number;
data: unknown;
}

A step chooses the function with service_method: fetch, create, update or delete. Without it, fetch is called.

Argument Holds
operation The step’s operation. The step’s name if it has none.
params The values the step’s input_map resolved to. {} without one.
config adapter and trust from the service, then the keys of the step’s config block.
signal An AbortSignal that fires when the step’s time limit passes.

This step:

only:
description: Call the adapter.
execution: auto
uses_service: crm
operation: get_customer
config: { region: eu }
input_map:
id: run.params.id

in a run started with {"id":"C-9"}, called fetch with:

{
"operation": "get_customer",
"params": { "id": "C-9" },
"config": { "adapter": "crm", "trust": "engine_delivered", "region": "eu" }
}
The function The step
Returns { status, data } with an object as data Completes. Its output is data.
Returns { status: 200, data: 42 } Completes. Its output is {"data":42,"status":200}.
Throws an Error Fails: Step 'only' failed: Adapter 'crm' threw: the CRM is down
Throws a WorkflowError Fails with that error. See Handlers.
Is delete, and the adapter has none Fails: Step 'only' failed: Adapter 'crm' does not support service_method 'delete'

Realm does not read status. An adapter reports a failure by throwing.

A step may have a config block only if its adapter has a config_schema, a JSON Schema for that block. realm workflow validate and realm workflow register check the block against it:

The step Message
Has a config the schema refuses Invalid workflow: Step 'only': config validation failed against adapter config_schema: must be equal to one of the allowed values (step at line 10)
Has a config, and the adapter has no config_schema Invalid workflow: Step 'only': 'config' declared but adapter 'plain' does not declare 'config_schema' (step at line 10)

realm agent --workflow does not make this check. It ran both workflows above, and the adapter received the refused config.

Command Loads the code If loading fails
realm workflow validate, register, watch Before it accepts the workflow. The workflow is refused.
realm workflow test Before the fixtures run. The command fails.
realm agent --workflow, realm workflow run Before the run is created. No run is created.
realm agent --run-id Before it continues the run. See below.
realm listen When it starts, for every workflow it mounts. It does not start.
realm mcp, realm serve The first time a tool call needs the workflow. The tool call fails.
realm run respond, realm run drain, realm run advance (added after version 0.46.0) When it runs. The command fails.
realm-mcp, from the package @sensigo/realm-mcp Never. A step that needs project code is blocked.
realm run list, inspect, attempts, diff, replay, export Never.

With a code file that throws when loaded, realm run inspect, list, replay and diff gave their usual output.

realm mcp and realm serve keep the code they loaded until the process ends. After a code file changes, restart the process. realm listen loads again in each realm agent it starts.

realm agent --run-id and code that does not load

Section titled “realm agent --run-id and code that does not load”
The run After the failed command
Has run no step yet It is ended as failed, with the cause extensions_load_failed.
Has run at least one step It is as it was. A run waiting at a gate was still waiting at the gate.
Error: Failed to import module '../ext/a.mjs' (/srv/shop/ext/a.mjs): boom at import
Phase: failed
Sealed by: extensions_load_failure
Cause: extensions_load_failed

After the file was fixed, the same realm agent --run-id command opened the run again and ran it.

realm agent, realm workflow run, realm workflow validate, realm workflow test, realm mcp, realm serve, realm run respond, realm run drain and realm run advance (added after version 0.46.0) take --extensions-module <path>. The file is loaded in place of every file the workflow names. It does not have to be inside the project.

[realm] --extensions-module override active: loading '/srv/repair.mjs' (resolved: /srv/repair.mjs). Declared workflow extensions are IGNORED.

The flag does not replace realm.yaml.

When a step runs, Realm records which code the process had loaded. realm run inspect prints it:

Extension Identity (1 entry):
1. captured 2026-10-02T00:21:08.513Z (pid 3318070)
module: ../ext/a.mjs -> /srv/shop/ext/a.mjs (esm)
entry_hash 35391d53865df6a1acba50fbda535cd9de562acbc927ce3d10340fbdc0ab4c9a
tree: 5 files, 637 bytes
tree_hash a2b2f92cfc3d679cfeae27ac52d4343972aaaed81ab143d6dbff914c50d706f1
signals: package_version 1.4.0
coverage (dir_tree_v1): covers files under /srv/shop/ext matching dir_tree_v1: include .js,.mjs,.cjs,.ts,.mts,.cts,.json; exclude dirs node_modules,.git; skip symlinks; sort by relpath; sha256(relpath\0filehash); caps 2000 files/50MB; imports outside these roots, node_modules, and runtime dynamic imports are NOT covered.
Part Holds
captured When the code was loaded, and the ID of the process that loaded it.
[override] Printed after the process ID if --extensions-module was used.
module For each file: the path as written, the path it resolved to, the format, and a SHA-256 hash of the file.
tree One hash over the code files in the folders that hold the named files, with the number of files and their size.
signals The version in the project’s package.json, and the commit that .git/HEAD points to. Recorded, and not compared.
coverage Which files the tree hash covers.

The tree hash covers files ending in .js, .mjs, .cjs, .ts, .mts, .cts or .json, at any depth under the folder of each named file. It leaves out node_modules, .git and links. It stops at 2000 files or 50 MB. A file that the code imports from another folder is not covered.

For a project with a realm.yaml, the record also holds that file’s path and hash, and the names of the secrets it refers to. See Deployment manifest.

Realm adds an entry when a step runs and the loaded code differs from the last entry. A run was paused at a gate, a code file was edited, and a new realm agent --run-id continued the run:

[realm] WARN: extension code identity differs from this run's last recorded identity — recorded tree_hash a2b2f92c… (captured 2026-10-02T00:21:08.513Z), current tree_hash b71e8739… (captured 2026-10-02T00:21:25.606Z). Advisory only — the run proceeds; …
Extension Identity (2 entries):
1. captured 2026-10-02T00:21:08.513Z (pid 3318070)
…
2. captured 2026-10-02T00:21:25.606Z (pid 3333013)
…

The run goes on. The warning does not stop it.

realm run inspect <run-id> --check-drift hashes the files on disk again and compares them with the last entry:

Drift check (pure recompute of the last execution entry under its recorded rules):
module /srv/shop/ext/a.mjs: same
tree: same

A file that differs is shown with both hashes:

module /srv/shop/handlers.mjs: DIFFERS (recorded 1bce8906e68a…, current 7c022774ac3b…)

The check compares files on disk with what the run recorded. A running realm mcp or realm serve may still hold older code than the disk.

A workflow made with the create_workflow tool has agent steps only, and the tool takes no extensions. Realm also refuses to load code for a stored workflow that is marked as made by an assistant and has extensions.

npm can install a realm package more than once on one machine: once in your project, and once more with the realm command, or nested under another realm package. Your code imports the copy nearest its own file. The realm command imports the copy installed with it. This page calls each of them a copy.

Your code and the realm command must use the same realm version. Copies of one version work together: an error or a provider made with one copy is recognised by the other. Copies of different versions do not, even one patch apart. The same holds for a program of your own that uses the realm packages as a library.

Copies of one version working together was added in 0.46.0. On 0.45.0 two copies do not recognise each other’s objects, whatever their versions, so matching version numbers are not enough there: your code and the command must share one copy. Install @sensigo/realm-cli in the project beside @sensigo/realm, at the same version, and run it with npx realm.

With different versions, Realm objects do not cross versions: a WorkflowError your handlers or adapters throw is not recognised — its step fails after one attempt, without that error’s own code and retry setting. From a copy that carries the release mark, the message names both versions and both folders, and the reply of the call that ran the step (start_run or execute_step over MCP) carries the code ENGINE_RELEASE_LINE_MISMATCH. From an older copy with no mark (0.45.0 and earlier), the step fails with the ordinary failure message plus a note — the class carries no release mark, what was not used if it is realm’s, and the way out — and the reply carries ENGINE_HANDLER_FAILED (ENGINE_ADAPTER_FAILED for an adapter); Upgrade Realm shows one. Either way the run record keeps the message only. A handler of the project’s 0.46.1 copy under a realm agent of 0.46.0 fails its step like this (the project’s copy is 0.46.0 given the version number 0.46.1 for this example; folders shortened):

✗ Step 'only' failed: Handler 'flaky' threw a WorkflowError from realm 0.46.1 (/srv/shop/node_modules/@sensigo/realm); this engine runs realm 0.46.0 (/usr/lib/node_modules/@sensigo/realm-cli/node_modules/@sensigo/realm). Realm objects do not cross versions, so its code 'SERVICE_RATE_LIMITED' and its retry setting were not used. Install @sensigo/[email protected] (and every other @sensigo package the project has, at 0.46.0) in the project your code imports it from, or, when you run the realm command, run version 0.46.1 there: 'npm install --save-dev @sensigo/[email protected]', then 'npx realm'.

Where your code is loaded, the command warns about it first, as REALM_RELEASE_LINE_MISMATCH:

⚠ Your project's @sensigo/realm is 0.46.1 (/srv/shop/node_modules/@sensigo/realm, installed by the project); this realm command runs @sensigo/realm 0.46.0. Realm objects do not cross versions: a WorkflowError your handlers or adapters throw is not recognised — its step fails after one attempt, without that error's own code and retry setting. Install @sensigo/[email protected] (and every other @sensigo package the project has, at 0.46.0) in the project your code imports it from, or, when you run the realm command, run version 0.46.1 there: 'npm install --save-dev @sensigo/[email protected]', then 'npx realm'.

A provider module given to realm agent --provider-module whose LlmProvider comes from another realm-cli version is refused with Error: the provider module's LlmProvider comes from @sensigo/realm-cli <its version> (<its folder>); this realm command is @sensigo/realm-cli <the command's version> (<its folder>). … and exit code 1. The message ends with the two ways out: run the project’s own realm at its version (npm install --save-dev @sensigo/realm-cli@<its version>, then npx realm), or install @sensigo/realm-cli and @sensigo/realm at the command’s version in the project — installing only the matching @sensigo/realm-cli would let the provider through and fail at the first handler error instead. These messages were added in 0.46.0; on 0.45.0 the step fails as ENGINE_HANDLER_FAILED and the provider is refused with Error: provider module default export must be an instance extending LlmProvider.

To see the versions, run realm --version, and in the project npm ls @sensigo/realm @sensigo/realm-cli. Every number must be the same. To fix a difference, install every realm package the project uses, and the realm command, at one exact version (see Upgrade Realm). Or install @sensigo/realm-cli in the project at the same version as the project’s @sensigo/realm, and run it with npx realm.

A package of handlers or adapters that other projects install should declare @sensigo/realm in peerDependencies with a caret range on the version it was built against (for example "^0.46.0"), and also in devDependencies, so that its own tests have a copy. When the project’s version is inside the range, the package uses the project’s copy. When it is outside, npm with its default settings refuses the install with ERESOLVE. A package that declares @sensigo/realm as an ordinary dependency at a version other than the project’s brings a second copy, and Realm does not recognise that copy’s errors.

A program that hands Realm a store of its own (a run store, a workflow store, a trace buffer or a failed-attempt store) must declare which realm it belongs to, once, from the @sensigo/realm the store imports its errors from:

import { declareReleaseLine } from '@sensigo/realm';
class TenantScopedRunStore {
/* … */
}
declareReleaseLine(TenantScopedRunStore);

A store built as a plain object is declared with declareReleaseLine(store). Realm’s own store classes already carry their line. A store that declares none is refused with ENGINE_RELEASE_LINE_UNDECLARED; a store from another realm version with ENGINE_RELEASE_LINE_MISMATCH — by createRealmMcpServer, every published tool handler, the exported engine functions that take a store, and the two trace-buffer constructors that take a run reader, before any work. A program that builds the MCP server inside an http request handler must catch the refusal: a handler that throws ends the process. This was added in 0.46.0.