Brain Content Layout
Brain behavior lives in features: one directory per feature under features/, holding everything that defines it — scenario, pipeline code, heartbeat, hooks, role. Inside a feature the engine discovers entities by file suffix, not by folder structure. features/ is the only home of behavior entities; constitution/ keeps configuration and system prompts only.
Suffixes
| Entity | Suffix |
|---|---|
| Scenario (workflow blueprint) | *.scenario.md |
| Scenario blueprint (sibling) | *.blueprint.yml |
| Scenario pipeline (sibling) | *.pipeline.ts |
| Heartbeat (periodic prompt) | *.heartbeat.md |
| Hook (event handler) | *.hook.md |
| Role | *.agent.md |
| Feature context (what the feature adds to a turn linked to it — contexts.md) | *.context.md |
| Loop module (sibling of a scenario or heartbeat) | <id>.loop.ts |
| Feature metadata + switch (optional) | FEATURE.md (exact name, top of the feature dir) |
The id of an entity is its filename without the suffix. features/weekly-report/report-reminder.heartbeat.md → id report-reminder.
Feature layout — flat, all G
Each feature is one directory. The whole tree is G: volume-mounted, git-managed by the brain-sync daemon. Full contract: contract.md § 2–3.
features/<name>/
├── FEATURE.md # optional: name, description, enabled switch, README body
├── *.scenario.md # workflow blueprint
├── *.blueprint.yml # optional sibling blueprint
├── *.heartbeat.md # periodic prompt
├── *.heartbeat.ts # its gate — optional code condition asked every tick, before any agent (contract.md § 3)
├── *.hook.md # event handler
├── *.hook.ts # its gate — optional code condition beside the hook (contract.md § 3)
├── *.agent.md # role
├── *.context.md # feature context — instructions + views a linked turn carries
├── *.pipeline.ts # Restate primitive (primary + 0..N auxiliaries)
├── <id>.loop.ts # optional loop module beside its entity
├── *.ts # any helpers the pipeline imports
├── fixtures/, README, ...
- The whole
features/tree is mounted as a writable subpath of the brain state volume. Dashboard edits, agent edits, and the brain-sync daemon round-trip changes back to the brain git remote. - Pipeline code is G too, but only the
*.pipeline.tsfile is hot; its helpers wait for a restart, and a push is a deploy — contract.md § 2.1. Editing it with runs in flight: pipelines.md § Editing a.pipeline.tswith in-flight invocations. - Pipelines belong to a scenario by name, anywhere inside its feature — not by the directory they sit in.
<scenarioId>.pipeline.tsand<scenarioId>-<suffix>.pipeline.tsare that scenario’s, whether beside it, in a subfolder, or next to another scenario. Lay the feature out however you like. A scenario that owns no*.pipeline.tssimply has no pipelines — it runs asengine=workflowunless it explicitly declaresexecution.engine: pipeline, which is then a contract violation (rule 6 below), not a silent downgrade.
Features
features/ is an engine-convention directory, like constitution/, skills/ and memory/ — derived from the brain root, not declared. defineProject has no paths key: passing one throws, with a message telling you to move the entities into features/<feature>/.
- A feature is a top-level directory under
features/. Its id is the directory name. There is no registry —mkdir features/billingis a new feature. The name must be an entity id (it starts with an ASCII letter, digit or_, then ASCII letters, digits,_ . -, no..); entities in a directory with any other name never load and are reported.engineis reserved — it is the id of the engine’s built-in feature (itssystem:roles) — so afeatures/engine/folder is refused the same way, anddefineProjectrefuses to boot on the entities inside it. Hidden directories (.drafts/) andnode_modules/are not features — every walk passes over them. A symlinked directory is a feature like any other. - Entities live inside a feature, at any depth.
features/<feature>/is walked recursively and matches are filtered by suffix;features/adhoc/<chatId>/x.scenario.mdbelongs to featureadhoc. A subfolder is also a part of the feature for its contexts: a context reaches the runs of the entities in its own directory and below (§ 2 there, The reach), so a feature can hold several parts, each with its own contexts. - A skill only one feature needs lives in that feature. A directory holding a
SKILL.mdanywhere below the feature root (features/dutch-coach/skills/arie-method/SKILL.md) is a skill of that feature: it is in theskills: ['*']catalog of that feature’s own roles and of no other (a role elsewhere still gets it by naming it), it is off while the feature is switched off, and deleting the feature deletes it. Shared and installed skills stay inskills/. Its name shares the one skill namespace: a feature skill named like a skill inskills/,skills/engine/or another feature is refused, never an override —omnislash doctorfailsSkill nameson it. - Every entity carries its feature. Scenarios, heartbeats, hooks and roles expose
feature(the feature id) next to their frontmatter; the dashboard shows it as a chip and links to the Features page.
FEATURE.md — name, description, icon, switch
Optional, at features/<id>/FEATURE.md:
---
name: Operations # optional, defaults to the directory name
description: "On-call handover, support digest, cost report." # optional
icon: wrench # optional, a Lucide icon name (https://lucide.dev/icons/) from feature_set_icon's list
enabled: true # optional, default true
---
What this feature does, who owns it, what it depends on. # the feature's README
- Absent file ⇒ the feature is on, named after its directory.
- The schema is strict. Only
name,description,icon,enabled. A typo such asenable: falseis an error, not an ignored key — the one thing this file does is switch behavior off, so a misspelt switch must not silently leave it on. - The icon. The dashboard draws feature icons from Lucide — it ships
lucide-react1.47 — in its own stroke and tone. Write the name exactly as lucide.dev lists it: lowercase words joined by-(wrench,chart-line,users,messages-square), unquoted — never an emoji, which fails the file like any schema error. The dashboard carries a curated part of Lucide (work, people, money, content, code, places — 115 names), andfeature_set_icontakes only those: itsvalueenum is the list, so pick from it rather than from the whole site. A well-formed name outside the list still loads and shows the neutral feature glyph; noicon:shows no icon at all.iconis optional. - The body is the feature’s README — shown on the dashboard’s Features page.
- Agents write it through tools, not by hand.
feature_set_enabledandfeature_set_iconchange one key each — dry run first (dryRun: truereturns the diff and, for a switch-off, how many live pipeline runs it kills), then commit with the returnedshaasexpectedSha; a file someone else changed in between is refused, never overwritten.
The feature switch
Each entity’s effective enabled = its own frontmatter enabled AND its feature’s enabled. The loader stamps two values on every entity:
| Field | Meaning |
|---|---|
declaredEnabled | The entity file’s own enabled: — true when the file omits it, for scenarios, heartbeats and hooks alike. What an entity toggle (dashboard, patch tools) reads and writes. |
enabled | Effective: declared AND feature on. What every engine consumer acts on — scheduler crons, hook dispatch, pipeline registration, the heartbeat worker, doctor, the loop tick. |
- Switching a feature off never touches entity files. Only
FEATURE.mdchanges (it is created withenabled: falsewhen the feature had none). Switching it back on restores each entity’s own flag — an entity that was off stays off. - Roles switch too. A
*.agent.mdin a switched-off feature resolves like a missing role: runtime role lookup returns nothing, the prompt builder skips it, and role pickers stop offering it. DoctorAgent rolesflags every enabled scenario, heartbeat and hook (standalone or scenario-inline) that dispatches to a role whose feature is off — keep shared roles in a feature you never switch off (brain-example usesfeatures/team). - Switching off a feature with pipelines stops them for real. The pipelines service drops the feature’s Restate services, and the orphan drain kills their in-flight invocations — no compensation runs, and nothing is paused. To let runs finish, stop their triggers first and switch off once
run_list({ feature, kind: ['pipeline', 'workflow'] })shows none active. The same happens within a few minutes when a single pipeline scenario is switched off. - Manual runs follow the switch.
/runlists only scenarios that run; a/planthat writes an ad-hoc scenario executes it directly, whatever theadhocfeature says. - The switch is hot.
FEATURE.mdis part of every entity loader’s fingerprint, so flipping it self-corrects the caches exactly like an entity edit; the pipelines service re-registers when the switch changes which pipeline scenarios are enabled. - A broken
FEATURE.mdholds its whole feature. A file that fails to parse or fails the strict schema makes every entity in that feature a skip with causeunparsable, pointing at theFEATURE.mdpath. Unlike an entity file, a schema error here is stillunparsable: the engine cannot tell whether the author meant the feature on or off, so nothing is torn down — the scheduler holds those crons (their ticks run nothing until the file is fixed), and entity-repair tries the file once (it can only fix broken YAML; a misspelt key stays for the author). Hooks and pipelines of the feature stop meanwhile — they have no schedule to hold.
Misplaced entity files fail boot
An entity or feature file (.scenario.md, .heartbeat.md, .heartbeat.ts, .hook.md, .hook.ts, .agent.md, .pipeline.ts, .blueprint.yml, .loop.ts) that sits anywhere under constitution/, directly in features/ rather than inside a feature directory, or in a features/ directory whose name is not a valid feature id, would never load. defineProject refuses to boot and lists every such file:
Brain entities live only in features/<feature>/ — these files would never load:
- constitution/Heartbeats/morning-brief.heartbeat.md
- features/stray.hook.md
Move each into a feature directory: features/<feature>/<file>.
A file that lands there after boot (an agent write, a brain-sync pull) is reported by doctor Brain entities as [misplaced] <path> (or as a loader skip, for one under features/) — and the next restart of any service refuses to boot until it is moved.
Walk rules
- Dirs skipped during the walk, anywhere inside a feature (and in
constitution/):node_modules/,.git/,.omnislash/,.claude/,.codex/,.agents/,artifacts/,memory/,dist/,build/. At the top offeatures/they are feature names like any other (features/memory/is the featurememory), except hidden dirs andnode_modules/. - Malformed YAML frontmatter skips the entity, loudly. A file whose frontmatter fails to parse (unquoted
:inside a value, missing closing---, …) is excluded from the load with a structurederrorlog naming the file, the line, the column and the YAML error. It does NOT degrade to defaults — silently loading such an agent with the default model and full prompt cascade is exactly the failure mode this prevents. - One YAML engine, read and write. The engine parses and serializes brain frontmatter with the same YAML 1.2 engine, and one splitter decides where the frontmatter block ends for parse, repair and body-replace alike. (Historical trap: reading with a YAML 1.1 parser while writing with 1.2 meant the engine could emit a document it could not read back —
schedule: 12:30written unquoted re-parsed as the number750, the schema rejected it, and the scenario silently lost its schedule. Sexagesimals and leading-zero octals are the only 1.1↔1.2 divergences that reach brain content.) - A skip has a CAUSE, and it is load-bearing.
unparsable= the frontmatter is not YAML, so the engine does NOT know what the author declared;rejected= it parsed and the kind’s schema refused it, so the engine knows exactly what it says. Onlyunparsableis held by the scheduler and offered to the repairer: a schema rejection (deletedschedule:,enabled: "false") is the author telling the engine the entity is gone, and holding it would keep a beat someone switched off firing forever. - Every skip is a doctor finding, not just a log line. The loader records each skipped file with its
id, path, cause and reason. TheBrain entitiesdoctor check reports them asfailacross all six kinds (scenario / heartbeat / hook / agent / system role / context) — a skipped file is an entity that silently never runs. Theidis derived from the FILENAME, so it survives content that does not parse — that is what lets the scheduler tell a deleted entity from an unreadable one. - A skip never unschedules anything. The scheduler holds the repeatable job of any entity whose file did not PARSE, and the heartbeat worker skips a tick of such a beat instead of removing its schedule — so the beat resumes on its previous schedule the moment the file parses again, instead of silently dropping off it.
- The engine tries to repair the skip before anyone notices. An entity-repair pass runs in the scheduler on boot and on every content tick, fixes the one shape brain authors actually produce — an unquoted plain scalar containing
:— by rewriting the FILE (only forunparsableskips; a schema-rejected file is valid YAML and is never touched), and raises to the status channel anything it cannot fix. The repair is written to disk (never applied in-memory) so brain-sync commits it back to the brain repo as a reviewable diff, and every repair candidate is re-parsed through the loaders’ own parser — a repair that cannot be proven to parse is discarded. The proof is syntactic ONLY — quoting is right fordescription: a: band wrong foragent: role: reviewer, which are the same syntax — so every repair is announced to the status channel, not just logged. Parsing itself stays fail-loud.
IDs
An entity’s id is its filename without the suffix — report-reminder.heartbeat.md is report-reminder. It must be an entity id: it starts with an ASCII letter, digit or _, then ASCII letters, digits, _ . -, no ... No : — an entity is referred to as kind:id (heartbeat:report-reminder), split on the first :. A file whose id is not one loads as nothing: a rejected skip reported by doctor Brain entities (rename the file).
A scenario’s inline hooks (hooks: in its frontmatter) carry no file of their own, so each declares its id: id: — an entity id without . (ASCII letters, digits, _ and -), unique within the scenario. The hook’s id is <scenarioId>.<id> (deploy.notify for id: notify in deploy.scenario.md), and it stays put when a hook above it is added or removed. A hook without id:, or two with the same one, fail the scenario’s load the same way. A standalone hook named after one (deploy.notify.hook.md) would share its id — and with it every hook: grant scope — so doctor Hook ids fails on it.
Uniqueness
The same id across two files — even in different features — loads neither. Every copy becomes a rejected skip naming the others, reported by doctor Brain entities and the entity-repair alert:
[heartbeat report-reminder] /app/brain/features/weekly-report/report-reminder.heartbeat.md
duplicate .heartbeat.md id "report-reminder" — also at /app/brain/features/legacy/report-reminder.heartbeat.md
Neither copy is picked by path order — which file runs must not depend on how directories sort. Rename one file, move one out, or merge them.
The shared agent: block
Scenarios, heartbeats, hooks and blueprints all declare who executes this through one shape — the AgentSpec. YAML accepts either a bare string (shorthand for role) or the object:
agent:
role: sre # {id}.agent.md in any feature, or a system role
modelRef: smart # overrides the role's default model
repo: engine # an attached repo's slug instead of the brain
systemPrompt: "…" # appended to the role's constitution
runIn: clone # execution context — live (default) | clone | sandbox
runIn is the one field whose reach depends on the entity:
| Entity | runIn |
|---|---|
*.heartbeat.md, *.hook.md (incl. scenario-inline hooks) | Honored. Each tick/fire mints a fresh agent row, so frontmatter is the only durable place to declare it — a run_in set by hand in the dashboard is gone by the next fire. |
*.scenario.md, *.blueprint.yml | Inert. These feed the workflow run scope, which always clones per run and shares that one checkout across the run’s task agents. Use execution.pullRequest: true for the PR path. |
Reach for runIn: clone whenever the entity’s prompt (or a skill it loads) tells the agent to branch-commit-PR: the default working copy is the live brain view, which in prod is not a git checkout.
runIn: sandbox additionally requires project isolation.enabled — and unlike a missing baseline, that one does not degrade. The tick/fire mints its agent row before it runs anything, and writing an unpersistable tier throws, so a sandbox declared on an isolation-off brain means the entity never fires at all. Use clone unless you specifically need the jail.
omnislash doctor → Agent runIn reports both: fail for a tier that cannot be persisted, warn for one that would silently degrade (no baseline) or is declared where it is inert. Full ladder: contract.md § 2.3.
Co-location example
One feature ships everything in a single flat directory:
features/weekly-report/
├── weekly-report.scenario.md # workflow blueprint
├── weekly-report.pipeline.ts # Restate primitive (default export)
├── weekly-report.hook.md # post-run hook
├── report-reminder.heartbeat.md # periodic decision prompt
├── event-source.ts # ingest adapter
├── snapshot.ts # domain helpers
├── notify-tool.ts # LLM tool implementation
└── env.ts # feature env schema
The suffix-based scan finds the entity files wherever they live; the pipeline runtime imports the code via standard Node module resolution.
Composite scenarios (multi-pipeline)
A scenario may own N *.pipeline.ts files, not just one. The primary is matched by name; auxiliaries are matched by prefix. Both are looked up across the whole feature, at any depth.
features/outreach-telegram/
├── outreach-telegram.scenario.md
├── outreach-telegram.pipeline.ts # PRIMARY: default export name='outreach-telegram'
├── outreach-telegram-vacancy.pipeline.ts # AUX: name='outreach-telegram-vacancy' (workflow)
├── outreach-telegram-contact.pipeline.ts # AUX: name='outreach-telegram-contact' (object)
└── outreach-telegram-account.pipeline.ts # AUX: name='outreach-telegram-account' (object)
Rules (checked at discovery — a scenario that breaks one is not served):
- Filename = primitive name.
basename === '<name>.pipeline.ts'where<name>is the default export’sname. - Primary required. A pipeline file whose
name === scenarioId(i.e. filename<scenarioId>.pipeline.ts). Cron triggers, the dashboard’s scenario-pipeline view, scheduler — all map to the primary. - Auxiliary names start with
${scenarioId}-. When several scenarios of one feature match a file (deployanddeploy-weeklyboth matchdeploy-weekly-report.pipeline.ts), the scenario named exactly after it owns it; otherwise a scenario that has its own<scenarioId>.pipeline.tsbeats one that has none (a workflow scenario never takes a pipeline scenario’s file), then one in the file’s own directory, then the longest id — so scenarios can share a directory. A*.pipeline.tsthat no pipeline scenario of its feature owns (no name matches, or only a workflow scenario’s does) is never registered: the pipelines service logs it andomnislash doctor(Brain entities) fails on it, but it does not stop the other pipelines. - Globally unique primitive names. Restate requires it; engine validates across the whole brain.
- Ownership stays inside the feature. A pipeline in
features/billing/never belongs to a scenario infeatures/ops/, whatever its name — the feature’sFEATURE.mdswitch must cover everything the feature runs. engine: pipeline⇒ resolvable code. A scenario that declaresexecution.engine: pipelinebut owns no<scenarioId>.pipeline.ts→ a reported failure, never a silent downgrade toengine=workflow. A*.pipeline.tsthat fails to import or exports nodefaultis the same class of error. What boot and hot reload do with a broken module: contract.md § 5.
The same rules, in the same code, also run without a server: omnislash doctor → Pipeline modules, or that check alone with omnislash doctor --check pipelines — the step your generated ci.yml and deploy.yml gates run, so a module that would not be served is red before it reaches the host. Every broken file is listed, not just the first. A module that reads env at import time cannot be checked on a runner without your deployment’s secrets and is reported as a warning — move env reads into the handlers.
When to use composite vs single-file:
- Single-file — one user-facing feature, one Restate primitive. Default.
- Composite — one user-facing feature, multiple primitives (typical: 1 cron
servicetriggers N keyedworkflows/objects).
Single-pipeline features keep N=1. Pipeline authoring itself: pipelines.md.
What stays in constitution/
Configuration and system prompts — nothing that is a behavior entity:
MANIFEST.yaml,models.json,engine-capabilities.json,engine-tools.json,project-capabilities.json,PHILOSOPHY.md- the system prompts below (
Agents/system/*,Grants/,Planner/,Evaluation/Methods/,Loop/)
System files — fixed paths, NOT suffixed
Engine reads a set of static templates by exact path. Canonical copies ship with the engine. Brain may override any one of them by placing a file with the matching path under its own constitution/ — override is per-file; a brain that ships only constitution/Agents/system/role-coder.md overrides just that file and inherits every other default from the engine. Do not rename:
| Path | Purpose |
|---|---|
constitution/Agents/system/every.md | Prepended to every agent system prompt |
constitution/Agents/system/role-<name>.md | Role-specific prompt (planner, coder, evaluator, …). Loaded via the system-roles loader (cached, supports optional YAML frontmatter: name, description, modelRef, skills, capabilities, mcpServers). <name> stays fixed because engine code references the system-role enum values — a file under any other name is skipped (doctor Brain entities names it, with the rename when there was one). |
constitution/Agents/system/util-*.md | Utility prompts — the system prompt of a plain LLM call that runs under no role (summarization, memory dedup, parse-schedule, …). An agent dispatch’s instructions live in its role-*.md instead. |
constitution/Agents/system/with-*.md | Prompt fragments (with-memory, with-state, with-environment, with-AUQ-*, …). An override keeps the engine’s {{…}} vars where it wants the engine-rendered parts: with-state.md’s {{state_guide}} (the pointer at the agent-state modelling guide), with-background-tasks.md’s {{later_bullet}}{{delegate_bullet}} (the grant-gated tool bullets; the Claude half adds {{subagents_bullet}}: briefing harness subagents, and harness subagent vs engine agent) and {{idle_note}} / {{hold_note}}, with-capabilities.md’s {{requestable}} (the capabilities the agent could ask for) and {{builtins}} (the Claude built-ins it lacks). An override without a var simply carries its own text. |
constitution/Agents/system/without-*.md | Fragment variants (without-questions, …) |
constitution/Grants/custom-grant-parser.md | Grant parser prompt |
constitution/Planner/Methods/Cascade/Stages/stage-*.md | Cascade planner stage prompts |
constitution/Planner/Methods/Cascade/<name>.md | Cascade planner method prompts |
constitution/Planner/Methods/OneShot/prompt.md | One-shot planner prompt |
constitution/Planner/Methods/HTN/{frame,decompose}.md | HTN planner prompts (frame + decompose) |
constitution/Evaluation/Methods/*.md | Eval methods (listed by dashboard; not an entity-loader kind) |
constitution/models.json | Model catalog (pricing, CLI/API capabilities). Convention path, derived from the brain root. |
The structure itself is an engine contract — renaming breaks the lookup.
Why keep them fixed
Three different mechanisms:
- Discoverable entities (scenarios, heartbeats, hooks, roles) — brain adds them inside a feature, engine finds them. Suffix-based: brain decides the name, engine scans
features/for anything matching. - Fixed-name, loader-backed (system roles:
Agents/system/role-*.md) — not features and never switched; names are engine contract (one file per system-role enum value), but loading goes through a cached loader that parses optional frontmatter. Lookup walks brain dir first, then engine templates; brain overrides individual roles by shipping a same-name copy. - Static templates (
Agents/system/every.md,with-*.md,without-*.md, Cascade stages, planner methods, eval and loop method prompts) — engine asks for them by exact name. Resolution chain: brainconstitution/<path>first; if absent, the engine-bundled template.
.agent.md and Agents/system/role-coder.md cannot collide: the suffix scan only picks files ending in .agent.md; role-coder.md doesn’t match.
Creating new entities programmatically
The engine’s file-creation paths always name a feature and write into features/<feature>/, creating the directory when the feature is new:
- New scenario —
features/<feature>/<id>.scenario.md(optionally a nested dir inside the feature). - Bot-authored ad-hoc scenarios —
features/adhoc/<chatId>/<id>.scenario.md. They form one feature,adhoc: switching it off takes them out of/runand off any schedule. The/planthat writes one executes it directly, so it is not stopped by the switch. - New role —
features/<feature>/<id>.agent.md. An agent creates one withrole_create, then adds its capabilities (and MCP servers, or a model whose command-line agent ignores the grant) withrole_widen, which a person approves; the dashboard’s New agent dialog makes the same two calls: it previews the whole file, shows the person the grant it asks for, and writes nothing until they confirm it — then creates the role and widens it back to back. The feature is the person’s choice (a new name creates one).
Moving a generated file to another feature afterwards is a pure filesystem operation — the id is the filename, not the path.
Env var substitution
Every discoverable entity (*.scenario.md, *.heartbeat.md, *.hook.md, *.agent.md) runs through env substitution at load time — both the YAML frontmatter and the markdown body. The same engine also powers MCP server URL/header interpolation and docker-compose template substitution during omnislash build. One syntax for load-time substitution — ${NAME} / ${NAME:-default}, POSIX / docker-compose style.
Fire-time payload substitution is a different engine with a different delimiter. A hook body is rendered at fire time through a template fill that replaces {{runId}} / {{slug}} / {{error}} and friends from the event payload. The two never overlap by design (the env renderer’s regex ignores {{…}}, the payload fill’s ignores ${…}), so a hook body can carry both — but writing a payload name as ${runId} means it is looked up in process.env, found missing, and delivered to the agent as the literal string ${runId}. LLM-facing prompt files (Cascade stages, eval, loop, system prompts) use the same {{…}} fill.
| Placeholder | Behavior |
|---|---|
${VAR} | Replaced with process.env.VAR. Kept as-is if unset — fails visibly downstream (schema validation, connection) instead of silently emptying. A warning is logged on load listing the file path and unresolved frontmatter names. |
${VAR:-default} | Replaced with process.env.VAR, falling back to the literal default when VAR is unset OR empty (POSIX / docker-compose semantics). |
Whitespace inside braces is NOT trimmed (${ VAR } is a literal, not a placeholder). No escape syntax, no nested refs, no expressions — add them when a real need appears.
Use this to keep private IDs and secrets out of committed brain content:
# features/support/triage.scenario.md
---
name: Triage
enabled: true
chatbot:
surface:
kind: telegram
chatId: ${TELEGRAM_STATUS_CHAT_ID}
---
Best practices
- Declare referenced vars in
defineProject({ env }). Undeclared vars still work (rawprocess.env), but they won’t appear in the env catalog, the doctor command, or the prod env-requirements check — so a missed var on a new deploy becomes a runtime surprise instead of a startup error. - Content hash includes interpolated values. The scenario content hash is computed over the substituted content, so changing a referenced env var counts as a scenario change — the scheduler will re-plan on the next reconcile. Intentional: the effective content really did change.
- Don’t interpolate secrets into logged fields.
name,description, and the markdown body are echoed into planner prompts and surface in Telegram status cards and LLM prompts. Keep secrets in connection-target fields only (chatbot.surface.chatId,agent.repo, webhook URLs). - Hook payload names use
{{…}}, not${…}.{{runId}},{{slug}},{{status}},{{error}},{{eventName}},{{hookName}}— filled at fire time from the event payload. A payload name written as${runId}silently reaches the agent as literal text (env lookup misses, load-time render keeps it). Reserve${…}in a hook body for genuine env vars.
Hot reload
The engine watches features/ and constitution/ — in all modes, including production: both are G, so a brain-sync pull (or a dashboard/agent edit) triggers the relevant cache reset within seconds, no restart. In the pipelines service the same watcher additionally re-imports a changed *.pipeline.ts and re-registers it with Restate, and rebuilds when a *.scenario.md or FEATURE.md edit changes which pipeline scenarios are enabled (any other frontmatter edit costs nothing). Not hot: a pipeline’s helpers (they wait for a restart) and new dependencies (an image rebuild) — contract.md § 2.1.
A pipeline module that does not load is reported, never silent: a hot reload never takes a scenario that serves now offline (it keeps the previous build instead), a boot serves everything else — contract.md § 5. While a failure is recorded, any *.scenario.md or FEATURE.md edit also retries the build, so switching the broken scenario off clears it too.
How often the engine re-reads your content — contentProbe
Entity caches do not wait for the watcher. Each loader re-checks features/
by itself — a recursive walk plus a read of every matched file and every
FEATURE.md — and
serves its cache only while what it reads is unchanged, so an edit self-heals on
the next read whatever produced it.
That check costs a walk. contentProbe is the ceiling on how often each loader
pays for one, in milliseconds per entity kind:
export default defineProject({
name: 'my-brain',
contentProbe: {
scenario: 2_000, // default 0
heartbeat: 0, // default 0
hook: 1_000, // default 1_000
agent: 0, // default 0
systemRole: 0, // default 0
context: 0, // default 0 — read at every agent turn's dispatch
},
});
Within N ms of the last walk a read serves the current cache untouched, so an
edit becomes visible within N ms rather than at once. 0 — the default for
every kind but hooks — means walk on every read.
The dashboard is the one exception: its file watcher vouches for the caches
(and the features listing), so a page read walks only after the watch reports
a change under features/ or constitution/ — an edit shows within the
watcher’s debounce (~300 ms), a write the dashboard makes itself at once. A
watcher that fails falls back to the walk above.
Raise scenario if your brain has a lot of scenarios. Several engine call
sites ask “is this scenario a pipeline or a workflow?” once per scenario — the
scheduler tick, the dashboard scenario list, pipeline discovery, the two
reconcilers — and at 0 each of those questions pays for its own full walk. A
few dozen scenarios is nothing; several hundred across many feature directories
is a burst worth collapsing onto one walk. The engine cannot pick the number for
you: what a walk costs is a property of your corpus, and how much staleness is
acceptable is a property of how you operate the brain.
hook ships non-zero because the engine knows that loader is read on every
event delivery, which is orders of magnitude hotter than the rest.
Only whole non-negative millisecond values are accepted, and the six kinds are the whole list — a misspelt key fails the config load rather than silently leaving the default in place.
Migrating from constitution/ entities
Brains that kept scenarios, heartbeats, hooks or roles under constitution/ (or pointed paths.contentRoots elsewhere) no longer boot until those files move. Step by step:
- Remove
pathsfromdefineProject. The key is gone;features/is fixed by convention. - Create feature directories. Group entities by what ships and switches together —
features/operations/,features/personal/, … Shared roles that many features dispatch to fit a feature of their own that stays on (e.g.features/team/). git mvevery entity file into its feature —*.scenario.md,*.heartbeat.md,*.hook.md,*.agent.md— together with its siblings: the scenario’s*.blueprint.ymland<id>.loop.tsmust end up in the same directory as the entity they belong to; its*.pipeline.tsfiles may go anywhere inside the same feature. Ids are filenames, so nothing that references an entity by id changes.- Keep or flatten a
spec/+code/split — your choice. Pipelines are found by name anywhere inside the feature, sofeatures/x/code/x.pipeline.tsworks as well as a flat layout. - Optionally add
FEATURE.mdper feature —name,description,enabled, README body. Without it the feature is on. - Run
omnislash doctor(or just boot). The boot error lists every entity file still underconstitution/or loose infeatures/; repeat until it is empty. System prompts underconstitution/Agents/system/,Grants/,Planner/,Evaluation/Methods/,Loop/stay where they are.