Skip to content

Deployment manifest (realm.yaml)

realm.yaml holds a project’s settings: how each adapter, handler and processor is built, where secrets come from, and how gates are posted to Slack. This page lists its 8 top-level keys, the rules for each, where Realm looks for the file, and what a run records about it. Every message shown came from a run of realm against a project at /srv/shop.

For a walk through a first realm.yaml, see Deploy a project.

Realm reads one file: realm.yaml in the top folder of the workflow’s project. The project is the nearest folder, at or above the workflow’s folder, that holds a package.json or a .git. If no folder above has either, the workflow’s own folder is the project.

The file is What happens
In the project’s top folder It is read.
Absent Nothing is built from settings. Only the filesystem adapter is available.
Between the workflow’s folder and the project’s top folder The workflow is refused, with the message below.
Above the project’s top folder It is not read, and nothing says so.
Named realm.yml It is not read, and nothing says so.
Error loading extensions: Deployment manifest at '/srv/shop/w/realm.yaml' will NOT be loaded — manifests are read only from the deployment root '/srv/shop/realm.yaml'. Move it to '/srv/shop/realm.yaml' (the nearest package.json/.git ancestor of the workflow), or add a package.json/.git at '/srv/shop/w' to make that the deployment root.

Realm records the project’s folder when a workflow is registered, and reads realm.yaml from that folder for every run of the workflow. A workflow that was not registered from a folder has no project. For those, --project <dir> on realm mcp, realm serve and realm agent names the project to use. See Deploy a project.

version: 1
secrets:
sources: [dotenv]
dotenv: config/prod.env
adapters:
stock:
use: http
config:
base_url: https://stock.example.com
auth: { type: bearer, token: '${secret:STOCK_API_TOKEN}' }
handlers:
order_total:
use: ./handlers.mjs#makeTotal
config:
unit_price: 12.5
processors: {}
notifiers:
slack_gate:
type: slack
config:
bot_token: '${secret:SLACK_BOT_TOKEN}'
channel_id: '${secret:SLACK_CHANNEL_ID}'
app_token: '${secret:SLACK_APP_TOKEN}'
runner:
cache:
economics: config/economics.yaml
Key Required Holds
version Yes 1.
secrets No Where secret values come from.
adapters No The adapters to build, by name.
handlers No The handlers to build, by name.
processors No The processors to build, by name.
notifiers No One notifier, slack_gate, which posts gates to Slack.
runner No The prices the runner pays. Added after version 0.47.0.
disclosure No The environment variables whose values Realm shows. Added after version 0.47.1.

Any other key is refused, at every level except inside a config block:

Error loading extensions: Deployment manifest '/srv/shop/realm.yaml' is invalid:
manifest: unknown key 'adapter'

A section may be empty or left out. A file that is empty, has no version, or has another version is refused:

The file Message
Is empty manifest: must be object
Has no version manifest: must have required property 'version'
Has version: 2 manifest/version: must be equal to constant
Is not YAML Deployment manifest '/srv/shop/realm.yaml' is not valid YAML: …, with the position.

Each of the three is a map from a name to an entry. The name is the one a workflow uses: adapter: stock in a service, handler: order_total on a step. An entry has 2 keys:

Key Required Holds
use Yes What builds the thing: the name of a built-in adapter, or a code file and one of its exports.
config No Settings handed to what use names. Any shape. Secret references are allowed in its strings.

For an adapter, use may be one of 10 names: github, slack, http, airtable, gorgias, shopify, notion, parcelpanel, filesystem, mock. The settings each one takes are in Adapters. The manifest has no catalogue for handlers or processors; the built-in handler check_quotes needs no entry.

A value that contains a /, or ends in .js, .mjs, .cjs, .ts, .mts or .cts, names a code file. The path is taken from the folder of realm.yaml. After # comes the name of an export. Without #, the default export is used.

handlers:
order_total:
use: ./handlers.mjs#makeTotal
config:
unit_price: 12.5

The export is a function. Realm calls it once, with the entry’s name as id and the entry’s config with its secrets filled in, and uses what it returns:

export function makeTotal({ id, config }) {
return {
id,
async execute(inputs) {
return { data: { total: 4 * config.unit_price } };
},
};
}

A handler must have an execute function, an adapter the functions listed in Project extensions, and a processor a process function.

Realm registers a processor under its name. No workflow field uses one.

The entry Message
Has no use handlers.show needs 'use:' — a catalog name (adapters) or a module reference './path.js#Export'.
Has a key other than use and config manifest/handlers/show: unknown key 'uses'
Is a handler or processor with a built-in name handlers.show — there is no built-in catalog for handlers; use a module reference './path.js#ExportName'.
Is an adapter with an unknown name adapters.crm — unknown catalog adapter 'salesforce'. Valid catalog names (realm v0.46.0): github, slack, http, airtable, gorgias, shopify, notion, parcelpanel, filesystem, mock. For a custom adapter use a module reference './path.js#ExportName'.
Gives config to filesystem or mock adapters.files — catalog adapter 'filesystem' takes no config; remove the 'config:' block.
Names a file that is not there Cannot resolve manifest module './nope.mjs' (from '/srv/shop/realm.yaml', resolved: /srv/shop/nope.mjs): ENOENT: no such file or directory, lstat '/srv/shop/nope.mjs'
Names a file outside the project Deployment manifest module '../outside.mjs' resolves to '/srv/outside.mjs', which is OUTSIDE the deployment root '/srv/shop'. Manifest 'use:' modules must live within the deployment root — …
Names an export the file does not have handlers.show — module './handlers.mjs#makeNothing' has no export 'makeNothing'. Available exports: default, makeShow, makeStrict, notAFunction.
Names an export that is not a function handlers.show — './handlers.mjs#notAFunction' must export a FACTORY (ctx: { id, config }) => instance; got object.
Builds a thing without the function its kind needs Extension processor 'clean' in '/srv/shop/realm.yaml#processors.clean': missing callable 'process' — processors must implement 'process' per the @sensigo/realm Processor interface.
Has a name that a workflow’s code file also registers Extension handler 'order_total' is declared by both '../reg.mjs' and the deployment manifest '/srv/shop/realm.yaml' — manifest entries and code-module exports share one namespace; names must be unique.
Is built by a function that throws constructing handlers.show (use: ./handlers.mjs#makeStrict) failed: bad token [REDACTED]

Each message follows Error loading extensions: Deployment manifest '/srv/shop/realm.yaml': or stands on its own after Error loading extensions:.

An adapter entry named filesystem replaces the built-in one, with a notice:

[realm] manifest adapter 'filesystem' overrides the built-in adapter 'filesystem'.

A string inside a config block may hold ${secret:NAME}. NAME is made of capital letters, digits and _. When Realm builds the entry, it replaces the reference with the secret’s value.

With SHOP_TOKEN=tok_from_dotenv in .env:

In config The function received
token: ${secret:SHOP_TOKEN} "token": "tok_from_dotenv"
header: "Bearer ${secret:SHOP_TOKEN}" "header": "Bearer tok_from_dotenv"
list: ["${secret:SHOP_TOKEN}", 7] "list": ["tok_from_dotenv", 7]
price: "$$5 and $${secret:SHOP_TOKEN}" "price": "$5 and ${secret:SHOP_TOKEN}"

$$ gives one $, so $${secret:NAME} is kept as text.

A reference is allowed only in a string under config: in an adapter, handler or processor entry, or in notifiers.slack_gate.config.

The reference Message
Is in use handlers.show.use: '${secret:FILE}' is not allowed here — secret references are legal only inside string values under 'config'.
Is in secrets.dotenv secrets.dotenv: '${secret:WHERE}' is not allowed here — secret references are legal only inside string values under 'config'.
Is in runner.cache.economics runner.cache.economics: '${secret:X}' is not allowed here — secret references are legal only inside string values under 'config'.
Has a name in small letters malformed secret reference '${secret:lower}' at handlers.show.config.a — the form is ${secret:NAME} with NAME = [A-Z0-9_]+.
Is not closed malformed secret reference '${secret:OPEN' at handlers.show.config.b — the form is ${secret:NAME} with NAME = [A-Z0-9_]+.
Key Default Holds
sources [dotenv] Where Realm looks for a secret’s value, in order. Each is dotenv or env.
dotenv .env The file read for dotenv. A path from the folder of realm.yaml, or a full path.
Source Realm looks in
dotenv The file, which holds one NAME=value a line.
env The environment of the Realm process.

In the dotenv file, a value in quotes ('…', "…" or `…`) may span lines. A carriage return written in the file is read as a line break. Inside "…", \n and \r are read as a line break and a carriage return, also after another backslash: \\n is a backslash and a line break. In a quoted value, every other character is kept as written: \" stays two characters, and so does \\ unless n or r follows it.

The first source that has the name gives the value. With SHOP_TOKEN=tok_from_dotenv in .env:

sources SHOP_TOKEN in the environment Value used
Not set tok_from_env tok_from_dotenv
[env, dotenv] tok_from_env tok_from_env
[env, dotenv] Not set tok_from_dotenv

The realm command also loads a file named .env in the folder it is started in into its own environment, or the file DOTENV_CONFIG_PATH names; a variable that is already set keeps its value, unless DOTENV_CONFIG_OVERRIDE is set in the environment it starts with (a line in that .env does not count; see Environment and files). So with sources: [env], a secret in that file is found when realm loads that file; otherwise it is found only if its environment already holds it — for a realm agent that realm listen starts, that is listen’s environment, which includes the keys listen loaded at start (from the .env of the folder it was started in, or the file DOTENV_CONFIG_PATH names).

Case Message
sources is empty manifest/secrets/sources: must NOT have fewer than 1 items
sources has another word manifest/secrets/sources/0: must be equal to one of the allowed values
The file named by dotenv cannot be read Deployment manifest: its declared dotenv file '/srv/shop/config/prod.env' cannot be read: ENOENT: no such file or directory, open '/srv/shop/config/prod.env'

Without a dotenv key, a missing .env is not an error: it is a source with nothing in it.

A command that runs steps refuses to start:

Error loading extensions: Deployment manifest: 1 secret(s) unresolved:
handlers.show.config.token → ${secret:SHOP_TOKEN}
handlers.show.config.header → ${secret:SHOP_TOKEN}
handlers.show.config.nested.list[0] → ${secret:SHOP_TOKEN}
Searched sources (in precedence order): dotenv (/srv/shop/.env).
Fix: add the missing name(s) to your dotenv file, or declare 'env' in secrets.sources and export them in the daemon's environment.

realm workflow validate, realm workflow register and realm workflow watch print the same text as a warning, add one line, and go on with a stand-in value, <sentinel:NAME>, for each secret:

⚠ Validating with SENTINEL credentials — execution paths still require real secret resolution.
Valid: show v1 (1 step)

register prints Registering in place of Validating. If a function refuses the stand-in value, its entry is skipped, with a warning:

⚠ Deployment manifest '/srv/shop/realm.yaml': constructing handlers.show (use: ./handlers.mjs#makeStrict) failed: bad token [REDACTED] (sentinel mode — entry skipped)

realm workflow test always uses stand-in values and never reads the sources.

When a function that builds an entry throws, Realm replaces every secret value of 4 or more characters in the message with [REDACTED], and also the forms these programs print it in:

  • JSON-escaped, as JavaScript’s JSON.stringify, Python’s json.dumps (every non-ASCII character as \uXXXX), Go’s json.Marshal, PHP’s json_encode (without its JSON_HEX_… flags), Perl’s JSON::PP, jq’s tojson, Newtonsoft.Json, Gson, System.Text.Json and Jackson write it, and inside another JSON string;
  • in quotes with backslash escapes, as Python’s repr(), ascii(), unicode_escape and repr() of bytes, Node’s util.inspect (console.log of an object), PHP’s var_export and addslashes, Perl’s Data::Dumper, Go’s %q, %#v and %+q, Rust’s {:?} and escape_default(), Ruby’s inspect, PyYAML’s and js-yaml’s double-quoted strings, Windows argument quoting (Python’s subprocess.list2cmdline) and bash’s declare -p write it, and with each " written \";
  • shell-quoted, as bash’s xtrace, ${v@Q} and printf %q, jq’s @sh, Python’s shlex.quote and BusyBox ash’s xtrace write it; and with each ' doubled (YAML single-quoted strings, SQL quoting), each " doubled (CSV quoting) or each $ doubled (Makefiles, Compose files);
  • with HTML or XML escaping, as Python’s html.escape and xml.sax.saxutils, .NET’s SecurityElement.Escape, jq’s @html, lodash’s _.escape, Ruby’s CGI.escapeHTML, Go’s html.EscapeString and PHP’s htmlspecialchars write it;
  • percent-encoded with upper-case hex, as JavaScript’s encodeURIComponent and encodeURI, Node’s querystring.escape, URLSearchParams and URL, Python’s quote, quote_plus and urlencode, Java’s URLEncoder, jq’s @uri, PHP’s urlencode and Go’s url.QueryEscape, url.PathEscape and url.UserPassword write it;
  • in base64 and base64url, also when other text was encoded with it (the forms GitHub’s runner and Jenkins mask, and, when its base64 ends in = padding, each without its last character), and in lower- or upper-case hex.

It also replaces each line of a multi-line value that has 8 or more characters and no spaces or other blank characters. For every value, it also hides, in the same forms, the value with these escapes read, when that has 8 or more characters: \n, \r, \t, \b, \f, \/, \\, \", \uXXXX and \xHH (a value that ends in a lone \ has no such reading); and each line of that which has 8 or more characters and no spaces or other blank characters. It also hides a secret’s parts, as Give an agent step tools lists them. In base64 and base64url, a value of 6 bytes or fewer that was encoded together with other text is hidden at some positions and not at others. It leaves alone what that page lists as left alone. With SHOP_TOKEN=secret-value-123 and a function that throws bad token ${config.token}:

Error loading extensions: Deployment manifest '/srv/shop/realm.yaml': constructing handlers.show (use: ./handlers.mjs#makeStrict) failed: bad token [REDACTED]

Such a message also hides the values of the environment, the value after a credential’s name and a token after Bearer or Basic , as a log does (added after version 0.47.1).

A declared secret of 1 to 3 characters cannot be hidden: Realm cannot tell it apart from ordinary text. When it reads the file, Realm says so on stderr, naming the file and each config key that refers to the secret, and goes on (added after version 0.47.1):

[realm] Secret 'SHOP_PIN' (in /srv/shop/realm.yaml, at handlers.show.config.pin) is shorter than 4 characters, so realm cannot recognise it in printed text without hiding ordinary words. It shows wherever it appears outside a credential's name: in replies, logs, exports and Slack posts (after a name such as password=, a surface that hides every kind still hides it). Fix: give it a value of at least 4 characters. It is hidden again from the next load at which it is that long.

notifiers has one key, slack_gate. It needs type: slack and a config block with any of 8 keys:

Key Type Holds
webhook_url String A Slack incoming-webhook address. Realm posts each gate to it.
bot_token String The Slack app’s bot token. Realm posts each gate with it.
channel_id String The channel the bot posts to.
app_token String The app-level token. With it, Realm reads replies over Socket Mode.
signing_secret String The app’s signing secret. With it, Realm reads replies sent to events_port.
events_port Integer 1 to 65535. The port Realm listens on for Slack’s requests.
reminder_interval_ms Integer 1 or more. How long before a reminder is posted. The default is 600000.
escalation_threshold_ms Integer 1 or more. How long before an escalation is posted. The default is 1800000.

realm agent uses these settings. See Answer gates from Slack.

The block Message
Has app_token and no bot_token notifiers.slack_gate: 'app_token' (Socket Mode) requires 'bot_token' — Socket Mode replies are posted via the bot token.
Has signing_secret and no events_port notifiers.slack_gate: 'signing_secret' (Events API) requires 'events_port' — the events listener needs a port to bind.
Has events_port and no signing_secret notifiers.slack_gate: 'events_port' requires 'signing_secret' — an events listener without request verification is not allowed.
Has both app_token and signing_secret notifiers.slack_gate: 'app_token' and 'signing_secret' are mutually exclusive — Socket Mode and the Events API are alternative reply channels; configure one.
Has another key manifest/notifiers/slack_gate/config: unknown key 'channel'
Has a type other than slack manifest/notifiers/slack_gate/type: must be equal to constant
Sits beside another notifier manifest/notifiers: unknown key 'email_gate'
Has events_port as a string manifest/notifiers/slack_gate/config/events_port: must be integer

A block with bot_token and no channel_id, and a block with an empty config, are accepted.

runner has one key, cache, which has one key, economics: the file of prices the runner pays. Realm reads it to put a figure in US dollars on each model call a run made, when the run is read. The path is from the folder of realm.yaml, or a full path. See Cache economics file for its format. Added after version 0.47.0.

The block Message
Has another key in cache manifest/runner/cache: unknown key 'foo'
Has another key manifest/runner: unknown key 'concurrency'
Holds a secret reference runner.cache.economics: '${secret:X}' is not allowed here — secret references are legal only inside string values under 'config'.

Upgrade every realm that reads this folder before adding runner:. Older realms refuse the key.

disclosure has one key, not_secret: the names of environment variables whose values Realm shows. Realm replaces every other environment value of 8 or more characters with [REDACTED] in what an agent, a log, a broadcast or an export is shown. A name counts when every manifest a program has loaded lists it, and only while no other variable that is not listed holds the same value. REALM_NOT_SECRET names more, for a program with no manifest. Added after version 0.47.1.

disclosure:
not_secret: [APP_REGION, LOG_LEVEL]
The block Message
Has another key manifest/disclosure: unknown key 'show'

Upgrade every realm that reads this folder before adding disclosure:. Older realms refuse the key.

Realm reads realm.yaml and the dotenv file each time a run starts. In one realm mcp process, with 3 runs of one workflow:

Before the run The handler’s config
Nothing {"token":"tok_first"}
.env changed to SHOP_TOKEN=tok_rotated {"token":"tok_rotated"}
region: eu added to the entry’s config {"token":"tok_rotated","region":"eu"}
You changed Reaches the next run of a running process
realm.yaml Yes.
The dotenv file, given as a path from the folder of realm.yaml, or the default .env Yes.
The dotenv file, given as a full path No, until realm.yaml changes or the process is restarted.
A variable read through env No. Restart the process.
A code file named by use No. Restart the process.
The economics file named by runner.cache.economics No. Restart the process. Added after version 0.47.0.

The full-path case was run: after /var/abs.env changed, the next run still received tok_first.

Each run records the path of the realm.yaml it used, a hash of the file’s bytes, and the names of the secrets it referred to. Values are not recorded there (a value passed as a run’s param is kept: see Give an agent step tools).

{
"manifest": {
"path": "/srv/shop/realm.yaml",
"content_hash": "39c37842415cda1977237058afd640076a8b8186acbc76245f5424957c1d7529"
},
"secret_names": ["OTHER_KEY", "SHOP_TOKEN"]
}

A run made after version 0.47.1 also records secrets_block_hash, a hash of the file’s secrets block alone. For the file at the top of this page it is f57efad2e80ef275ec3bbaf1b1b50421d8bb3273ca2a8cf47005e4166c701a07.

These are in the run’s record under extension_identity, and in its export. realm run inspect does not print them.

realm run export and realm run inspect read a run’s declared secrets from the realm.yaml it recorded. They treat the file as unchanged for the run while it still refers to every secret the run recorded and, when the run recorded secrets_block_hash, its secrets block is the same. Adding disclosure:, a handler with a secret of its own, or any other edit keeps earlier runs exportable. Removing a secret a run referred to, or changing the secrets block, makes them read partial, and realm run export then refuses them unless it is given --accept-partial-disclosure. A run made by version 0.47.1 or earlier recorded no secrets_block_hash, so for it only the names are compared: a change to the secrets block alone is not noticed. Added after version 0.47.1. realm run inspect <run-id> --check-drift compares the hash with the file on disk:

Changed since the run --check-drift prints
Nothing manifest /srv/shop/realm.yaml: same
A value in .env manifest /srv/shop/realm.yaml: same
realm.yaml manifest /srv/shop/realm.yaml: DIFFERS (recorded 39c37842…, current c1e68fc2…)
A code file named by use module /srv/shop/handlers.mjs: DIFFERS (recorded 1bce8906…, current 7c022774…)

The hashes are printed in full. A changed secret value is not drift. A changed reference is, because the reference is in the file.

realm.yaml decides which address each adapter calls, which secret it sends, and which code is loaded. Write access to a project’s folder is therefore the same as write access to its code. A webhook request or an assistant’s tool call cannot change which realm.yaml is read: the folder comes from the registration or from --project.