Brain Runtime Contract

One-pager for brain authors. The contract a brain repo must satisfy to run on the engine.

Engine = framework runtime. Brain = a user project that consumes it (analog: an iOS app on top of system frameworks). This file defines the five things every brain owes the engine: package shape (§ 1), path classification (§ 2), feature layout (§ 3), lockfile (§ 4), and database ownership (§ 8). Sibling docs: getting-started.md (zero to a first answer), production-runbook.md (putting a working brain on a server), operations.md (deploy/ops), brain-dev-guide.md (registry access + engine upgrades), database.md (writing your own migrations).


1. package.json + .npmrc — single-pin peer shape

@omnislash-ai/* packages are runtime-shared singletons. Brain declares them once, in peerDependencies, pinned to the current engine release version. Both install contexts (image-build and brain-author local) honor the same single declaration via standard pnpm peer-dep semantics — no duplicate version pin, no asymmetric “image vs local” sections.

.npmrc is two layers:

L1 — pnpm flags (every brain). Install-time guards against singleton skew:

  • auto-install-peers=true — when a peer is missing (brain-author local install, no workspace), pnpm auto-installs from the configured registry. Standard pnpm 8+ default; we make it explicit so the contract doesn’t depend on global config.
  • strict-peer-dependencies=true — when a peer cannot be satisfied by the workspace (image build) or by registry (local install), pnpm fails the install with a clear error instead of silently producing a second copy. This is the primary build-time guard against singleton skew. Without it, a version mismatch between brain peer pin and workspace member silently falls back to a registry copy under brain/node_modules/, and the runtime singleton probe (§ 5) becomes the only line of defense.

L2 — registry routing (external brains only). Tell pnpm where to fetch @omnislash-ai/* from. One line, and never an auth token. Required when any engine peer is registry-pinned (the external-brain shape).

{
  "name": "my-brain",
  "private": true,
  "type": "module",
  "peerDependencies": {
    "@omnislash-ai/cli":          "0.152.0",
    "@omnislash-ai/config":       "0.152.0",
    "@omnislash-ai/infra":        "0.152.0",
    "@omnislash-ai/pipeline-sdk": "0.152.0",
    "@omnislash-ai/types":        "0.152.0"
  },
  "dependencies": {
    "zod": "^4.4.3"
  }
}
# brain/.npmrc
auto-install-peers=true
strict-peer-dependencies=true
@omnislash-ai:registry=https://npm.pkg.github.com

Three lines, and that is the whole file. omnislash doctor’s Brain .npmrc check requires exactly these (two, for a brain whose engine peers are all workspace:*).

Auth belongs to the environment, not to this file. A committed //<registry>/:_authToken=${NODE_AUTH_TOKEN} line is a trap, not a contract: a project .npmrc outranks your ~/.npmrc, and pnpm expands an unset variable to an EMPTY token which it then uses — so the placeholder shadows your real credential and the install 401s. With no auth line there is nothing to shadow. The three carriers that replace it:

  • locally — your own ~/.npmrc;
  • in CI — the actions/setup-node step in the engine-generated deploy.yml / ci.yml, which writes a scoped npm userconfig from the NODE_AUTH_TOKEN secret;
  • for children the CLI spawns — omnislash init / upgrade resolve a token from your environment or ~/.npmrc and hand their pnpm install a process-local, owner-only userconfig.

Where the token comes from: brain-dev-guide.md § Setup.

Why one declaration is enough.

ContextWhat pnpm does
Image build, version range satisfies the workspace memberSymlinks brain’s node_modules/@omnislash-ai/* to the engine workspace member. Single physical copy. No registry fetch.
Image build, range does NOT satisfy the workspace memberstrict-peer-dependencies=true fails the install with a clear “unmet peer” error. Build aborts — correct: a version mismatch should fail-fast.
Local install, peer missingauto-install-peers=true fetches from the npm registry into brain/node_modules/.pnpm/. IDE has types, omnislash CLI lands on node_modules/.bin/.
Local install, peer cannot be resolved at allstrict-peer-dependencies=true fails the install with an explicit error.

The Dockerfile generator also passes --strict-peer-dependencies to the image-build install line as belt-and-suspenders.

zod is a peer, not an ordinary dependency. Every published engine package declares zod in peerDependencies (^4.4.3) and none of them carries it in dependencies. The floor is the oldest zod whose types the published .d.ts name: engine is built against a newer zod, and its declarations reference z.ZodPreprocess, which 4.3 does not export — a brain below the floor fails its own type-check inside node_modules/@omnislash-ai/*. zod v4 encodes its own version inside the type it produces, so a schema built from one installed copy does not satisfy a signature typed against another copy: two copies anywhere in the tree turn defineProject, withInputSchema and tool params into TS2589 Type instantiation is excessively deep and ZodObject is not assignable to ZodType at type-check time. Declaring it peer-only everywhere makes the brain’s own copy the single one in the tree, whichever version within the range the brain picks. So the brain keeps zod in its dependencies and owns the version. Other npm deps (e.g. @restatedev/restate-sdk-clients) stay in dependencies / devDependencies as usual.

omnislash upgrade (from brain root) rewrites the version pin in peerDependencies to the new engine release and runs pnpm install — one number, one section, one re-resolve. Manually: pnpm up "@omnislash-ai/*@X.Y.Z". The rest of an upgrade is the agent task it writes: upgrading.md.

Pins are exact (X.Y.Z, no range sigil). Every other version-bearing artifact upgrade maintains — the Dockerfile FROM tag, the deploy.yml header, the docs/engine/ stamp, the skills/engine/.engine-skills-version stamp — is an exact version; a range pin would let a plain pnpm install/pnpm update move the installed engine without regenerating those artifacts (exactly the skew upgrade exists to prevent), and would make requesting an older release impossible (pnpm resolves the range max). Only omnislash upgrade moves the engine version.

Forbidden: dependencies: { "@omnislash-ai/foo": "..." } and devDependencies: { "@omnislash-ai/foo": "..." }. Either entry tells pnpm to install a registry copy under /app/brain/node_modules/ at image build alongside the workspace match — two physical copies of the same package = singleton skew, runtime state bifurcation. strict-peer-dependencies=true does NOT block this on its own (regular deps are not peers). Enforced at install-time by omnislash doctor’s Brain package.json check — it fails on any @omnislash-ai/* key outside peerDependencies. The boot-time singleton probe (§ 5) is the runtime backstop. Keep @omnislash-ai/* in peerDependencies only.

Brain devDep peer waiver. Your local package.json carries whatever devDeps make sense for tooling (vitest, typescript, biome, @types/node, …). At image build, pnpm install --prod skips devDep installation but still resolves the full manifest. If a devDep’s transitive peer is exact-pinned and drifts from the engine’s lockfile (the known case: vitest → an exact-pinned @vitest/coverage-v8), the strict-peer check would otherwise abort the install. The engine’s workspace carries a peerDependencyRules.allowedVersions waiver scoped exactly to that dev-only edge; the @omnislash-ai/* singleton guard is untouched.

1.1 Version retention — the last 10 releases stay installable

The engine publishes to GitHub Packages, where private storage is metered against the org’s plan allowance. Crossing that allowance does not produce a bill — it refuses every publish, which is to say it stops engine releases outright. So the version history is bounded on purpose: a weekly job keeps the 10 newest versions of each @omnislash-ai/* package and of the ghcr.io/omnislash-ai/engine base image, and deletes the rest. Deletion is permanent; npm never lets a deleted version number be republished.

At the current release cadence ten versions is roughly a week of history.

What that costs you: a pin older than the retention floor stops resolving, and it fails at the worst moment — pnpm install --frozen-lockfile inside a deploy, with ERR_PNPM_FETCH_404, or your image build on a base tag that is gone. An image you already built keeps running untouched; what breaks is building it again. Two habits keep you clear:

  • Run omnislash upgrade regularly. It moves the pin and every artifact stamped with it in one step (upgrading.md).
  • If you need to sit on one version longer than that, say so. Versions that a live brain pins are protected by name in the engine’s retention job and survive the sweep — but only because someone put them on that list.


2. G / R / W path classification

Every path under /app/brain in a running container is exactly one of:

ModeMeaningSource of truthUpdated by
Ggit-backed; writable in prodbrain git remote (single source of truth)dashboard, agents, brain-sync daemon (continuous pull+commit+push loop)
Rread-only in prod; image-bakedbrain git remote at image builddev → git push → CI rebuild → deploy
Wwritable, host-bound, no gitruntime artifacts only (not in git)apps at runtime; bind-mounted from $DEPLOY_DIR/artifacts/

The G-set is your agents’ push surface — treat it as the thing you decide, not a cache setting. The daemon commits exactly the G-paths and nothing else, so an agent running in the live brain (runIn: 'live', the default) that writes outside the G-set does not merely get a stale read — the write is never persisted. The container where agent turns execute is writable, so the write succeeds and then disappears when the container is recreated. The agent is told this verbatim in its system prompt.

So the question when you decide whether a directory belongs in brainSync.additional is not “would this break boot” — it’s “am I willing to take an agent’s edit here without reviewing it”. If the answer is yes, put it in G; the agent’s work becomes durable and reaches the running bot within one daemon tick. If the answer is no, keep it in the R-set and have the agent go through runIn: 'clone' + commit + PR instead (§ 2.3) — that is the reviewed path, and it exists precisely so the G-set doesn’t have to grow to cover code.

Rule of thumb for what you add: content, notes, docs, prompts, agent configs → G. Anything that shapes CI or deploy → never G (the forbidden list below enforces it).

features/ is not yours to classify. It is engine-baseline G, code included (*.pipeline.ts, *.hook.ts, *.heartbeat.ts, *.loop.ts, their helpers, modules your config imports), and a brain cannot remove it. So for features/ code the decision you control is not where it lives but which agents may write it unreviewed: an agent in the live brain (runIn: 'live') can edit a features/*.ts file, and brain-sync commits and pushes that edit like any other. Whether you accept unreviewed agent code edits is your call. If you do not, route the agents that write code through runIn: 'clone' + PR (§ 2.3) and keep live-brain agents to content. When a features/*.ts edit takes effect, and what it can break on the way: § 2.1.

Adding a path to G gives you durability unconditionally. It gives immediacy when the reader re-reads per use or is covered by a content-watcher — which is the case for constitution prompts, scenarios/hooks/heartbeats/agents, models.json, access.yaml, skills, capabilities/tool policy and the *.pipeline.ts file itself (not the helpers it imports — § 2.1).

The G-set is composed in two layers (plus the runtime-only marking over them):

LayerSourceWhat
Engine baselineengine-fixedmemory/, constitution/, skills/, features/. Engine’s own write-paths (memory ops, dashboard MANIFEST patches, skill install/CRUD) + the brain-sync inbound contract (you push prompt edits to git, the container sees them in one daemon tick — no image rebuild). Brain CANNOT remove.
Brain extensionProjectConfig.brainSync.additionalWhatever else you want hot: CLI agent dirs (.claude, .codex, …), custom content — anything not in the forbidden set. Validated at defineProject() time.
Runtime-only (deploy-skip)ProjectConfig.brainSync.runtimeOnlyThe G-directories above whose whole tree the engine never loads — an Obsidian corpus, people/, agent reports. Engine tier: every .md + memory/. A push touching only these skips deploy.yml (no gates, no rebuild, no recreate) and the Deploy Drift alert ignores it — one resolved list (resolveRuntimeOnlyDirs) for both. ci.yml carries no such filter: it has no push trigger at all — Deploy runs the gates on every push (brain-dev-guide.md § CI/CD). Rejected: a non-G-path (image-baked, only a deploy carries it) and anything loaded at boot (features/, constitution/, skills/). Declare the directory here — never hand-edit paths-ignore under an .override: that makes the deploy skip what the alert counts (omnislash doctor → Workflow filters).

Per-path classification (the resolved G-set):

PathModeSourceNotes
memory/Gengine baselineLong-term memory + memory logs. The worker autonomously writes daily files.
constitution/Gengine baselineConfiguration and system prompts only — models.json, MANIFEST.yaml, access.yaml (who may use which bot where — chat-access.md), the capability/tool JSONs, Agents/system/*, planner / evaluation / loop prompts. The dashboard prompt editor + MANIFEST patches write here. An entity file (*.scenario.md, *.agent.md, …) placed here fails boot.
skills/Gengine baselineTwo roots. skills/<name>/ + .lockfile.json are yours. skills/engine/<name>/ is the engine’s: mirrored from the installed engine on every omnislash upgrade, anything you write there is swept (opt out of the whole corpus with a skills/.engine-skills.override sentinel; skills/engine/.engine-skills-version is the convergence stamp). To change an engine skill, copy it into your root — omnislash skills fork <name> — and yours wins by declared name from then on, while the engine’s keeps updating beside it. Per-skill enable/disable decisions live here too, in skills/.settings.json — `{“$schemaVersion”: 1, “enabled”: {"": true
features/<name>/Gengine baselineAll brain behavior — *.scenario.md, *.heartbeat.md, *.hook.md (+ its *.hook.ts gate), *.heartbeat.ts gates, *.agent.md, *.pipeline.ts, FEATURE.md, helpers, fixtures. The only place entities load from (content-layout.md). The whole tree ships via brain-sync, but not all of it is hot: a *.pipeline.ts edit activates on the next brain-sync pull (re-import + in-place re-registration; in-flight invocations replay on it), while its helpers and every other boot-imported features/*.ts activate only when the process restarts — in production, the container recreate a non-prose push deploys. Details and risks: § 2.1. New npm deps in a pipeline image-bake — see § 2.1.
.claude/, .codex/, .agents/, …Gbrain extensionCLI agent configs. Consumed by external CLI processes per invocation, so dashboard / git push edits propagate via brain-sync without a rebuild. The CLI tools also write runtime files back into these dirs — those subpaths MUST be excluded from git (see § 2.2).
assets/Rimage bakeBrain-owned static brand files. Two separate assets, each probed first-match-wins in the listed order. assets/favicon.svg|png|ico → served at /favicon.ico by both the dashboard and the published-artifacts origin: the square mark sized for a 16px browser tab and the HITL desktop notification. assets/logo.svg|png|webp → served at /logo: the brand lockup the dashboard renders beside the brain name in the sidebar and mobile header, free to be a wide wordmark (it is rendered at a fixed height, width follows your artwork). Ship both, either, or neither — a missing logo falls back to the favicon, and a missing favicon to the engine placeholder. Convention, not config: the SPA is built into the engine image before any brain exists, so brand files can only ever be a runtime lookup against the brain root.
everything not excludedRimage bakeBrain source tree image-baked via a single COPY . ./brain/. Conventional paths: omnislash.config.ts, env.schema.ts, env.ts, tools/, db/, plus your own (ingest/, integrations/, …).
artifacts/Whost bindRun artifacts; no git.

Forbidden. defineProject() throws with a pointer to the offending entry. Two reasons, both hard:

  • Structural — package.json, pnpm-lock.yaml, pnpm-workspace.yaml, .npmrc (read by pnpm install at image build — G means excluded from the image, so it wouldn’t build); db/ (the migration one-shot reads it from the image and runs before brain-sync, which itself waits on the migration — G here is a deadlock); omnislash.config.ts, env.schema.ts, env.ts, tools/ (root of the boot import graph); node_modules/, .git/, .env.
  • Blast radius — .github/ (pushing workflow files needs workflow scope on the token, without which the daemon’s push fails entirely, not just for that path; and CI edits are arbitrary code on the runner); docker/ (your compose override is applied by the next deploy — an agent that can commit it can mount / or the docker socket into a container); Dockerfile, Dockerfile.override, .dockerignore.

G-paths must be directories. The sparse-checkout anchor is /<path>/, and git never matches that against a blob — so a file entry never lands in the volume, and every write into it is lost on the next container recreate. A symlink pointing at a real directory fails identically (git stores it as a blob), which is why the check doesn’t follow links. defineProject() rejects both; omnislash doctor’s Brain G-paths check reports the same. Nested directories are fine — .aider/cache works.

A path that doesn’t exist yet is fine — declare it and it boots as an empty directory that an agent can fill later; brain-sync creates every G-path in the volume before any service starts, so a missing one can never block a boot. omnislash doctor still warns about it, because the likelier explanation is a typo: if you meant docs and wrote dosc, your real docs/ edits silently never propagate.

Want a single hot file? There is one workaround and it is markdown-only: keep CLAUDE.md / AGENTS.md in the R-set and put the body in a G-directory — CLAUDE.md containing @constitution/agent-guide.md gives you a hot-editable guide behind a stable baked file. JSON configs (.mcp.json, opencode.json) have no include mechanism, so they stay cold: changing one needs an image rebuild.

Implementation: the G-set lives in the named volume brain_state, exposed to brain-consuming services via Docker Compose subpath mounts (one per G-path). R-set is COPY-ed into the image. W-set is a host bind. brain-sync mounts brain_state whole (where .git lives) and runs sparse-checkout with anchored patterns built from the resolved G-set, so only the G-paths land in the volume. The daemon is the workspace’s only writer and no agent can reach it; the dashboard alone mounts it whole, read-only, to read the brain’s history (git log) for the “Start a loop” chooser.

brain-sync/ is an engine-reserved branch prefix in your repo, like the .omnislash/ directory. When the daemon’s rebase onto your push conflicts, it force-pushes its unpushed local commits to brain-sync/conflict before spawning the resolver, and deletes the branch when the conflict is resolved. The generated ci.yml / deploy.yml trigger on your default branch only, so it runs no workflow. It is also your manual escape hatch: fetch it, rebase it onto your branch, push — the daemon picks the result up on its next tick. Do not create branches under that prefix yourself.

Everything (brain-sync commit set, sparse-checkout anchors, the prod subpath mounts, the .dockerignore exclusions) derives from one resolved G-set — there is no second place to keep in sync.

The engine itself writes into the G-set, in one narrow case. A *.heartbeat.md / *.scenario.md / *.hook.md / *.agent.md whose YAML frontmatter stops parsing is repaired in place — the engine quotes the scalar it read as broken, and brain-sync commits it like any other change. It verifies the file parses, NOT that the change means what you intended (description: a: b and agent: role: reviewer are the same syntax), so every repair is announced to your status channel and the diff is yours to revert. See operations.md § Runtime persistence.

Brain cannot shrink defaults. The brainSync.additional shape is additive-only by construction; there is no knob to replace the baseline. (An earlier narrowing knob was removed because the dashboard wrote to skills/ while a brain had dropped skills/ from the sync set — runtime writes were silently lost between restarts.)

The R-set is not enumerated: the Dockerfile emits a single COPY . ./brain/ after the workspace-integration pnpm install. .dockerignore is the contract — the engine generates it from the resolved G-set + ephemerals (node_modules/, .env, .git/, .github/, .omnislash/, artifacts/, lockfiles, Dockerfiles). Drop conventional files at the root and they ship — adding env.schema.ts, ingest/ (integrations.md § Event sources), integrations/, or any new path requires no engine change.

2.1 Image-baked dependencies (the asymmetry)

The G-set syncs bidirectionally, but two classes of dependencies declared inside G-content still image-bake, because dep installation runs at image build:

Declaration siteHot-reloads via brain-syncNeeds omnislash build
features/<name>/*.pipeline.ts importsEdits to the *.pipeline.ts file itself — re-imported and re-registered once brain-sync pulls them. Not the helpers it imports (below).Adding a new npm dep: land it in brain package.json first, then rebuild.
skills/<name>/SKILL.md frontmatter install[].kind: apt|uv|nodeSKILL.md prompt/metadata edits; adding/removing a skill from the lockfile.Adding a skill that needs a new system package (apt-get, uv tool install, npm install -g).

Rule of thumb: new files = hot. New external deps = cold.

What a features/*.ts edit activates, and when. The whole of features/ is G, but “synced” is not “live”. Three cases, and this is the one place they are stated:

  1. *.pipeline.ts — hot. When brain-sync pulls an edited pipeline file, the pipelines service re-imports it and re-registers its one Restate deployment in place (force: true); in-flight invocations replay on the new code, and the engine replays each live run against it (on a hot reload before the new code serves) and ends or restarts one it cannot replay by the scenario’s onCodeChange — what is safe to change under them and what happens to the runs an edit breaks: pipelines.md § Editing a .pipeline.ts with in-flight invocations. A reload whose module does not load is reported, and never takes a scenario that serves now offline (§ 5).
  2. Everything else a process imports at boot — not hot. The helpers a pipeline imports (./x.ts) and every other features/*.ts the engine imports at boot or through another module — modules your omnislash.config.ts references (tool handlers and the like), and the files a *.hook.ts gate or a *.loop.ts module imports. Node keeps the first version of each in its module cache until the process restarts, and editing a helper does not even trigger a pipeline rebuild. (The *.hook.ts gate and the *.loop.ts module themselves are re-imported when their own bytes change — their imports are not.) A helper edit therefore goes live at the next restart of that process — in production, normally the next deploy.
  3. A push is a deploy. Any push to main that touches more than runtime-only paths (§ 2: .md, memory/, brainSync.runtimeOnly) runs deploy.yml, which recreates every app container (bot, worker, pipelines, … — every service on your brain image; Postgres, Redis and Restate are untouched). That recreate is what activates case 2, and it also interrupts in-flight chat turns and HITL conversations. Deploy waits on your gates (brain-dev-guide.md § CI/CD), so a red push never deploys — but brain-sync’s hot reload (case 1) does not wait: the next brain-sync tick (BRAIN_SYNC_INTERVAL_MS, 5 minutes by default) hot-reloads an edited *.pipeline.ts whatever the gates say — still running, green or red. A red gate therefore leaves the new pipeline file live while the deploy, and with it every helper edit, is blocked.

Whether an agent may make such an edit unreviewed is your decision (§ 2): runIn: 'clone' + PR puts a human and the gates in front of all three cases.

The bake provisions the skills you have turned on, not everything in skills/: it resolves activation exactly as the runtime does (skills/.settings.json entry, else the skill’s frontmatter defaultEnabled, else on). So an opt-in skill costs you nothing until you enable it — and enabling one that needs binaries is a rebuild, not a hot change. omnislash skills enable|disable regenerates the Dockerfile for you; commit .settings.json and the Dockerfile together, and omnislash doctor warns if they drift apart.

2.2 .gitignore rules for .claude/ / .codex/

.claude/ and .codex/ are G-set only if you declare them (brainSync.additional) — and once declared, configs (settings.json, MCP lists, hooks, config.toml) round-trip via brain-sync. But the CLI tools write runtime files back into the same dirs — claude lands transcripts under .claude/projects/<uuid>/, todo state under .claude/todos/, local permission overrides in .claude/settings.local.json; codex writes session caches under .codex/sessions/. None of this belongs in git. Without .gitignore discipline, brain-sync commits and pushes those files every tick, growing the repo without bound.

The same dirs are a natural brainSync.runtimeOnly entry: the engine never loads them, so a skill script or settings.json an agent writes there is live the moment brain-sync commits it — undeclared, that commit is a non-.md change and triggers a full redeploy mid-conversation.

Recommended exclusions (single root .gitignore):

.claude/projects/
.claude/todos/
.claude/shell-snapshots/
.claude/statsig/
.claude/ide/
.claude/settings.local.json
.codex/sessions/
.codex/log/

settings.local.json is the one most often missed: your machine probably excludes it through a global gitignore (Claude Code’s own convention), which the prod container does not have — so it looks handled locally while the daemon commits the container’s permission state every tick.

omnislash doctor’s CLI agent .gitignore check warns when any of these are missing for a dir in the G-set — declaring .claude is what arms the check, not creating the dir, so you get the warning before the first agent spawn rather than after it.

2.3 Agent runtime cwd — per-run clone, not /app/brain

The G/R/W table classifies paths under /app/brain — the shared brain view that brain-consumer code (bot reading memory, dashboard editing scenarios) reads. Agent runs do not execute there. Each agent run gets its own working directory:

  • Workflow run cwd = a per-run git clone --shared at $RUNS_ROOT/run/<runId>/brain, branched wf/<runSlug>. Created on every run whenever a baseline is available — unconditionally. Every task agent of that run works in it for as long as the run owns the turn, and returns to its own context (below) once the run is done — so a chat continued with a run’s agent afterwards runs wherever that agent’s own run_in points (the live brain by default), never in the run’s clone. The run must have let go first: agent_send (the dashboard composer’s send, and the tool), agent_send_command, agent_compact and agent_flush_mailbox refuse a turn while a non-terminal run still owns the agent (an active interception exempts agent_send — a person’s, or the agent’s that opened it), since such a turn would run outside the run’s checkout. The programmatic verbs (agent_call / agent_enqueue / agent_task) are NOT gated — a brain that dispatches into a run’s task agent by id is doing so deliberately. The run’s agents commit && push to persist; the finalizer merges back.
  • Standalone agent cwd follows the persisted agents.run_in (chat agents, heartbeats, hooks, a single-agent micro-brain) — the RunIn ladder live ⊂ clone ⊂ sandbox, chosen when you create the agent (telegram wizard, the dashboard’s New session, or agent_create), declarable in *.heartbeat.md / *.hook.md frontmatter, and overridable per-dispatch:
    • clone / sandbox → a git clone --shared under the agent scope ($RUNS_ROOT/agent/<agentId>/brain, branch agent/<id>); the agent persists via git add/commit/push, same as a run task. sandbox adds a bwrap jail (needs isolation.enabled) — and a CLI harness: the jail is applied when the engine spawns the CLI subprocess, so an agent whose modelRef resolves to an API model runs unjailed on every tier. A bare role (smart/fast) resolves API-first; write cli:smart when the jail is the point.

    • live (default; a trusted single-tenant brain) → the agent runs in the live brain checkout itself, no clone. Its writes to the G-set are committed + pushed by the brain-sync daemon, so it must NOT git push — the prompt tells it so and lists the synced directories. This is what makes “just write the file, it syncs” work for a personal brain.

    • The clone gate is run_in ∈ {clone, sandbox}; the bwrap jail additionally needs project-level isolation.enabled. Persisting sandbox requires isolation.enabled; clone is always available (it just falls back to live when there’s no baseline, e.g. dev).

    • Per-dispatch override — agent_task / agent_call / agent_enqueue ({ runIn }). A live-brain agent that needs to durably edit a cold file (anything outside the G-set: CLAUDE.md, omnislash.config.ts, tools/, db/, package.json) — where a plain write into live /app/brain is ephemeral (lost on redeploy) — delegates the edit to a sub-agent spawned with runIn:'clone', overriding that sub-agent’s row default for the one turn. runIn selects the cwd independent of isolation.enabled: 'live' (default), 'clone' (its own git clone --shared with a pushable remote, no bwrap — the sub-agent commits + opens a PR, the human gate), 'sandbox' (clone + bwrap jail for untrusted code). The durable-repo-edit skill teaches the clone→edit→commit→PR procedure. One engine agent runs clone and pushes the branch directly: the git-conflict-resolver that lands the daemon’s exported brain-sync/conflict commits — content the G-set’s unreviewed push surface would have pushed unreviewed anyway, so there is no gate to bypass; its role forbids touching anything but the conflicting files.

    • Declared in frontmatter — agent: { runIn }. A heartbeat tick and a hook fire each mint a fresh agent row, so a run_in you set by hand in the dashboard is gone by the next tick. The agent: block is the only place a periodic or event-driven agent can declare its execution context durably:

      ---
      schedule: "*/30 * * * *"
      agent:
        role: sre
        runIn: clone      # own checkout, pushable origin → this agent can open a PR
      ---

      Give it clone whenever the prompt or the skill it loads tells the agent to branch-commit-PR. Without it the agent’s cwd is the live brain view — not a git checkout — and a model told to open a PR there does not stop: it starts inventing detours (ssh to the host, ask a managed-DB advisor). A missing path is worse than a forbidden one.

      Prefer clone over sandbox here unless you specifically need the jail. sandbox requires project isolation.enabled, and that gate does not degrade: the tick/fire mints its agent row before it runs anything, and writing a tier the brain can’t persist throws — so a sandbox declared on an isolation-off brain means the heartbeat never fires at all, with no agent row to show the error on. omnislash doctor (“Agent runIn”) reports that as a failure, and a clone/sandbox that would merely degrade for want of a baseline as a warning.

    • Never write into the baseline ($BRAIN_BASELINE). It is owned by the brain-sync daemon, which pushes whatever branch it is on — an agent that leaves a topic branch there stops brain sync for everyone. clone exists precisely so an agent never needs to reach for it.

    • A scenario’s agent: block is run-scope, so runIn there does nothing: a workflow run always gets its own clone with a pushable origin, and every task agent shares that one checkout (that is what lets phase N hand artifacts to phase N+1). Use execution.pullRequest: true for the PR path. omnislash doctor (“Agent runIn”) flags a runIn declared where it can’t take effect too. The same holds for memory in that block — a task agent’s memory is its dispatched role’s own — and omnislash doctor (“Agent memory”) reports it.

  • /app/brain is not the agent cwd in prod when sandboxed, and in prod it is not a git repo (composite R-set + brain_state subpath views). For isolation-off agents the cwd IS /app/brain (G-set subpaths RW-mounted from brain_state) and the daemon owns persistence.
  • Fallback: when no baseline exists (local dev — no brain-sync), every run executes in the brain checkout directly, which in dev/start is a bind-mounted real git repo. Nothing is lost.
  • A run pinned to a commit executes the brain at that commit. workflow_runs.content_sha (NULL for every ordinary run) is set only by the loop’s pre-merge check — the candidate at its PR head, the incumbent at the default-branch tip. Such a run’s checkout is cut the same way and then DETACHED at that commit (fetched from origin when the baseline lacks it, under the checkout’s own credential), marked inside its .git, and every dispatch that attaches to it reads the run’s CONTENT — the scenario and its .blueprint.yml, the role a task agent runs as, the skills catalog — from that checkout rather than the live root (brainContentFor, packages/config/src/brain-content.ts); the scenario’s own skills list and agent spec come with it, while its declared grants and its evaluation config are not content and stay live. What is NOT content stays live: the eval method and judge, memory (READ live — a replay writes none of it, below), omnislash.config.ts, models.json, grants, the manifest, heartbeats, hooks, planner methods. Content includes the role’s FRONTMATTER (the model and the capability grant a task agent is pinned with at create, resolveAgentParams) and the brain-overridable prompt templates beside the role files (every.md, with-*.md — BrainContent.readConstitution); three turn-time frontmatter reads stay live by design, the context policy, memoryFlush and memoryKeep, because they govern the agent’s runtime budget and its memory — and memory is never content. A pinned run is a REPLAY, asks nobody and has no side effects: its input is judged by the blueprint AT THAT COMMIT (the handler’s load-blueprint step — the live blueprint lends a pinned submit only its slug, and no pre-run input card is opened: a pinned blueprint that wants input it was not given fails the create), its task agents are non-interactive (no question card), a capability that needs a human’s grant fails the run (replay: gated capability <id>) instead of carding it, the scenario’s scoped hooks are not subscribed, the PR finalizer does not run, its agents are minted no git credential, its memory is read-only (memoryWriteScopes: [] — memory_write / memory_edit / memory_write_file / memory_move / memory_delete answer “memory is read-only for this turn; nothing was written; do not retry” and enqueue nothing, the tool list staying the live one, and no turn flushes), every sub-agent its agents spawn through the agent-control tools inherits all of that (AuthContext.pinnedRunId, handed down by markSpawnedBy — the pin, never the run’s ownership: the child runs on the pinned content in the run’s checkout, holds no git credential, writes no memory, asks nobody, and fails instead of falling back to the live brain once that checkout is gone; the run’s own task agents carry the same pin beside runId, so they fail the same way rather than falling back), its failure spawns no remediation agent (RecoveryAgentSpawner skips it), and it is never retried (submitRetry on it is rejected by the handler — a new measurement is a new run). Only the brain repo can be pinned — a blueprint whose agents run in an attached code repo fails the create. Without a baseline (dev) the pin is cut from the live brain’s own git tree; a pinned run never degrades to the live root. What a replay reads, it reads live — every read tool runs; what it would WRITE, LAUNCH or SEND outside itself it does not: the run is loop-launched (§ 9.2), so every engine tool that acts on the world answers a success marked dry-run and performs nothing, and your own tools and pipelines are held to the same contract. Memory writes were the first instance of that rule and stay fenced by the pin as well: memory is an INPUT of the entity under measurement, not its output, so N inputs × trials × both sides writing the live line would contaminate each other and the live corpus. The risk ceiling for choosing replay at all is resolveCheckMode (moderate). The checkout is both the content the run reads and the tree its agents work in (a live run’s agents work in a clone while its content is the live root), so a task that edits the brain’s own files changes what a later phase of the same replay reads; a later attempt reuses the checkout only while its HEAD still stands on the pin — the run’s own commits on top count, a tree that walked elsewhere is re-cut.
  • A loop target is an entity, not an engine. Every loop row, event, tool and ?selected= link names its subject (targetKind, targetId) with targetKind a LoopTargetKind — scenario, heartbeat or role (@omnislash-ai/types/entity) — so a scenario that gains a .pipeline.ts keeps its cycles, lessons, ideas, holds and eval window; evaluations.target_kind is generated from run_kind (both engines’ runs grade their scenario) and the eval window is indexed on (target_kind, target_id, created_at). RunKind stays on runs alone (evaluations.run_kind, run_signals, runRef). What a target’s executions run as is asked at call time (LoopTarget.runKind: a scenario’s engine) and picks the proposer brief (constitution/Loop/proposer/<runKind>.md) and whether the pre-merge check can replay. A cycle waiting on a person is awaiting_decision (CYCLE_STATUSES, @omnislash-ai/types/execution-status, each with its reader label in CYCLE_STATUS_LABELS).
  • A cycle is a line of rounds, and a round owns its PR. loop_proposals ((cycle_id, round) unique, parent_id self-FK) carries what one round proposed (suggestions, summary, the refinement that asked for it), its status (proposed → accepted | skipped | refined, then applied | failed), decided_by / decided_at, cited_lessons, and everything its PR carries: pr_url, what the apply delivered (implementer_summary — the implementer’s own report of what it applied and skipped, null for a proposer pipeline’s files — and landed_files, the paths the PR carries, both written by markApplied), revert_pr_url (+ revert_merge_gate, the gate the undo stands under, and revert_outcome / revert_merged_at once the wait on the revert ends) or revert_error (why a change owed an undo has none — the change is still live), checked_sha, check_run_ids / baseline_run_ids (both GIN) and check_verdict. A refinement is a CHILD round, never an overwrite — decideProposal moves the parent to refined — and a retried failed round returns to accepted on its own row. findCycleByCheckRunId answers { cycle, proposal } through that join (a graded replay back to its round), the unacked-failure health predicate reads the revert’s outcome through it, and every lifecycle frame carries proposalId beside prUrl. The cycle row keeps only what it fixes or measures ONCE across all rounds — judge, frozen, the watch’s record (watch_window, rewritten on every tick; watch_verdict, validation_run_ids, validation_terms, challengers_viable/total, written once at the settle), merged_at (GitHub’s instant — “shipped”; watch_started_at is only the evidence boundary), merge_gate (the EFFECTIVE gate, written at the arm and again at a demotion — its value at the merge is which gate let the change through, what the breaker reads) + merge_wait_started_at (the merge is due at it + watch_deadline_hours), the acks, agent_ids, lesson_error (a lesson the settle owed and the archive refused), the stage clock — because at most one round merges. Row-owner writers: recordProposal (in record-analysis), decideProposal (in gate’s decide-proposal, decided_by = the round card’s HITL respondedBy), and markApplied / recordCheckedSha / recordCheckRuns / recordCheckVerdict / recordRevertPr / recordRevertOutcome on the round standing now; recordMergeGate / demoteMergeGate on the cycle. What a person owes the loop — every gate kind — is one statement, listCyclesAwaitingPerson(rearm); the breaker’s rearm entities are the caller’s input, since tripped-under-a-declared-auto-gate needs the resolved autonomy block.
  • A cycle freezes what it measures on. loop_cycles.frozen (FrozenSet: baseSha + evidenceRunIds + inputs, each { id, input, source: history | golden, incumbent, heldOut }) is written once beside loop_cycles.judge, right after the first analysis creates the row, and never recomputed — a refinement round reuses it. The judge (JudgeSnapshot, packages/types/src/eval.ts) is declaredJudge at the freeze: the pinned modelRef (judgeModelRef), methodRef / methodHash, and judgeDigests — loopModuleHash plus evaluatorRoleHash, the digest of the system:evaluator role file that resolves (brain override, else template), whose body is the judge’s own part of its system prompt — the role file only; the shared every.md / with-*.md fragments composed around it are not pinned, so an edit to them mid-cycle goes undetected. evaluatorRoleHash is .optional() on the wire because an eval job or a jsonb row written one deploy back lacks it; absent compares as its own value in judgeDrift (matches only absent), so such a grade is never counted beside one that states a hash. A pinned grading (EvalService.resolveJudge with a frozen snapshot) dispatches under the frozen ref and never calls judgeModelRef — a live ref broken since the freeze cannot fail it — and refuses on method, module or role drift, with an error naming what moved (judge: method, loop module changed since the freeze; a snapshot frozen before the role hash existed reads judge: the cycle was frozen before the evaluator role was pinned). An unpinned grading whose judge will not resolve is a brain-config fault the worker throws as BullMQ UnrecoverableError (booking settled failed, no retries); doctor Eval judges runs the same judgeModelRef per entity. measureBrainMetrics returns the digest of the module that measured beside its values, and a digest that is not the grade’s loopModuleHash fails the measurement. History is the last loop.check.inputs distinct inputs among the entity’s graded runs through the window’s anchor — the evaluation of loop_cycles.window’s newest run; a window naming no runs freezes golden cases only (the read scans HISTORY_SCAN_LIMIT wide, deeper than the window; the cap is applied after dedup, by canonical JSON), the anchor’s input leading, then inputs none of the evidence runs ran on (heldOut: true), then shown inputs only when nothing else is left. evidenceRunIds is the same scan’s head, evidence.runs deep — the runs the proposer was briefed on, which the brief selects the same way through the same anchor (round 1 is staged before the freeze runs, so it cannot read them off the row); the check’s verdict reason names held-out k of n and k = 0 is a doctor hint. Excluding every run the loop itself launched (created_by.kind = loop, and any pinned content_sha) but not the runs it merely observed; golden cases come from defineLoop({ cases }); baseSha is origin/<default> as the remote holds it at the freeze (remoteBranchTip, read under the engine repo’s own credential, no fetch). An input’s incumbent names its graded run only while that grade is comparable to the pinned judge (judgeMatches) and holds a metric vector; otherwise the check re-runs the incumbent at baseSha — one rule, no knob. loop.check.mode is never persisted: a NULL frozen is the record that the cycle has no pre-merge check, which is what resolveCheckMode derives for every heartbeat and pipeline and for a workflow whose plan needs a capability a human has to grant, reaches above moderate, opens a PR or runs in an attached repo (agent.repo other than the brain), unless a tier declared replay on a workflow. The facts are read off the plan once (readCheckSubject, @omnislash-ai/capabilities, validating only the fields it reads) for the doctor and the freeze alike. A set the brain’s files will not yield (cases malformed, a declared replay with no readable plan, a remote with no default branch) settles the proposal skipped with a notice, like an unresolvable judge; a transient failure on the way (the DB read, the ls-remote) throws for the step’s retry instead.
  • A candidate is checked before it merges. For a cycle with a frozen set, check.ts (S7) runs once the apply PR is open, while the row is applied — launched by the ship phase, waited on by the check phase: resolve-pr-head records the PR head as the round’s checked_sha and sets the omnislash/validation commit status to pending on it (setCommitStatus, gh api, under the engine repo’s own credential — statuses: write is part of the App floor); the replays’ ids are derived and written on the round FIRST (check_run_ids, baseline_run_ids, write-once), then one launch-<i>-<t>-candidate per frozen input per check.trials submits the scenario pinned to the head, and one launch-<i>-<t>-incumbent pinned to frozen.baseSha for every input whose frozen entry carries no comparable grade, each under the deterministic key checkReplayKey(proposalId, i, t, side) (a relaunch answers duplicate); the move to check commits the launches; the wait is one collect-check tick per ring over the replays’ rows and grades up to check.deadlineHours from the move — the eval worker rings the cycle whenever a replay is graded, and the WATCH ladder is the backstop — and a replay with no row is put to the attempt itself (workflowRunClient.attemptOutcome) so a create that failed is booked as a run the change failed, with the error, on the first tick. Every grade a tick reads — a launched replay’s (collectLaunched) or a frozen incumbent’s — counts only while it was made under the cycle’s judge (judgeMatches against loop_cycles.judge, re-read inside the tick); a row re-graded under another judge while the check polls is ungraded. The replay’s eval job carries that judge by construction: enqueueRunEval refuses a loop actor whose cycle has no pinned judge with a TerminalError before booking, never grading it under the live one. The judge-health ceiling is checked on the candidate side; an incumbent with no grade on any input is inconclusive; otherwise the entity’s comparator answers over PAIRS (VerdictInput.pairs, one per frozen input per trial: the incumbent’s side — its frozen grade, or the fresh replay — beside the candidate’s, each a PairSide), reading every metric as the median of its per-input movements, viability input by input, and a critical metric as a per-input veto; VerdictResult.perInput records every pair. validated → status success, the comparator’s record on the round (check_verdict), the PR goes to its merge gate (auto-merge is armed only now, never at the apply, and bound to checked_sha via --match-head-commit — a cycle with no check arms bound to the head the apply opened the PR on, the diff gate’s, and the wait holds it to that head the same way; mergePrAuto answers armed or unavailable with GitHub’s reason and NEVER merges directly — unavailable demotes the round to human with an auto-merge unavailable notice; the merge phase re-reads the head and the arm (autoMergeRequest) on every ring — the PR ladder (2 min doubling to 6 h), rung early by the PR’s own events through a cycle resource watch (resource_watches.cycle_id on github:pr:<repo>#<n>; an event is a cue to re-read, never the answer, so a pull_request.auto_merge_disabled only makes the read happen sooner) — and demotes to human when the read says the arm is gone). The apply publishes omnislash/diff-gate: success on the pushed head BEFORE it opens the PR (status-diff-gate between push-branch and open-pr — a status that cannot be posted leaves a pushed branch, never a PR the round does not know) so a base-branch rule can require both statuses. A status post GitHub refuses for permission fails its step at once (TerminalError): on a failed gh api post the loop asks probeStatusesWrite under the same credential, and a 403 that is not a rate limit is terminal while a rate-limit 403, a 5xx or no answer keeps STEP_RETRY; the apply asks the same probe as its first step (check-statuses, ahead of the budget read, the implementer and a proposer pipeline’s gate) and fails the round naming the credential, the repository and the permission. The revert publishes both as success on its own head (revert-status-diff-gate / revert-status-validation) so that rule takes the undo; regressed → status failure, the PR is closed with the per-metric and per-input comment, the row settles regressed from applied with no revert; unvalidated → status error with the reason, the PR stays open and an auto merge gate is demoted to human for the round (where the status is required, error blocks the merge and the person’s decision is an admin bypass or a close). Terms that will not load or a comparator that throws are inconclusive, never fatal. After the merge every kind is observed by ONE function per tick: watch.ts (S9) reads loop.watch live (read-watch-mode; mode: none settles unvalidated by design with a notice), stamps when the watch opened, and on every tick (collect-challengers, one per ring, STEP_RETRY) re-loads the entity’s loop terms, asks its window at phase: 'watch' (since = the watermark; repair: the first watch.windowRuns runs graded since it opened, under the pinned judge), re-applies the engine’s filters to whatever it named (windowOutcomes — the entity’s own completed grades, never the loop’s replays, the pinned judge, booked after the watermark; dropped ids are counted as rejected and named in a negative settle reason), writes that window on the cycle as watch_window (the window’s progress — the sampler’s own { have, need, unit }, graded runs under both presets — carried as answered: the eval-forcing producer forces grades while it is short, have < need or absent, and the dashboard and loop_status read how far the watch has got off it, never off runIds.length or watch.windowRuns; the next cycle’s mark is its to), then asks its compare({ targetKind, targetId, spec, before, after, terms }) with the cycle’s window resolved as the brief resolved it and the observed one just sampled. null is “not yet”: park on the WATCH ladder, ask again on the next ring, until watch.deadlineHours closes the window unvalidated. A VerdictResult settles the row on the spot (db-settle-verdict, one CAS statement carrying the record as watch_verdict, the window’s runs as validation_run_ids, under repair the terms it counted by as validation_terms, and the tally) and is announced with its reason and perMetric; regressed takes the revert path. repair’s compare is the trigger’s question over the observed runs (triggeredWithin — the k-of-n count, or the entity’s own shouldOpen asked at every grade where a tier declared one): regressed the moment watch.minFailures are counted or the rule fires, whatever the rest of the window brings; validated on a full window with neither; null while the window is short — its perMetric is always empty, which is how the breaker reads an unvalidated under it as blind (D15: unvalidated is blind only when watch_verdict is null or its perMetric is empty). Judge health is the ENGINE’s read in the same step (observedHealth, the same scan over the same span, independent of what window answered): a verdict is withheld while the judge is over maxUngradedShare, and at the deadline (or once the window is full) that settles unvalidated via releaseUnvalidated with no record. Brain code cannot wedge the cycle: window and compare run inside the tick step under its 60 s inactivity timeout, and a throw from either — TerminalError included — becomes a fault that settles the row unvalidated naming the function (settleUnvalidatable); only the engine’s own read failing underneath them (EngineReadError) is rethrown so the step retries. The round keeps the check’s check_verdict through the settle. Nothing is re-executed.
  • A round’s change has one of two authors. loop_proposals.suggestions holds either the proposer agent’s suggestions (promptChanges / newSkills — the default author) or the files a brain’s own proposer pipeline wrote (kind: 'files', proposer = the pipeline id, summary, hypothesis, files: [{ path, content | null }], null deleting the file). Both meet the same rails — the protected-path floor, the edit boundary, the size bound, the measurement blocks, the PR — and every reader names the author: the decide card, the lesson recalled into the next brief (proposer), loop_history (proposer column) and the dashboard. Columns: self-evolving.md § Reading the history.
  • A proposer is your pipeline, called by the loop. loop.proposer.pipeline: <pipeline id> (engine null = the proposer agent; MANIFEST.yaml; entity frontmatter, where null takes the proposer agent back) names a served restate.service whose run handler the loop sends a ProposerBrief — the proposer agent’s own evidence as typed JSON (the entity’s definition, the window, the opening grade, the runs, metrics, lessons, the proposals line and the relatedFailures — every document the proposer agent reads, as the value its file serializes — and instructions, the loop.proposer.instructions of both tiers kept apart), bounded by loop.evidence and loop.lessons.maxRecalled, the round a refinement is about (refining), plus the rails its files meet. The send is keyed per round, so it starts one run; the loop does not wait inside anything — your run’s own row is the answer, and the engine rings the cycle when the run ends (a cycle also looks on its own every few minutes, doubling to two hours). At most reviewDeadlineHours, then the call is cancelled and the round fails; switching the entity’s loop off cancels the call and ends the cycle. The call is loop-launched (ctx.loopLaunched, its own ctx.runRef): it acts on nothing and can ask nobody. It answers { summary, hypothesis, files, citedLessons? } or null; null — or no return value at all — settles the cycle skipped (no round, no card, no lesson, not a person’s rejection), a shape the engine refuses, a reply that is not JSON or a TerminalError fails the round with a Retry card. The engine writes the files itself — no implementer — through the same surface and diff gate (whose walls include the *.pipeline.ts of every proposer the loop names — no author edits itself), then the same PR, check, watch, revert and lesson. The proposer agent’s ban on READING constitution/Evaluation/** and *.loop.ts does not bind a proposer pipeline: it is your code; those paths stay closed to its writes. An id the brain does not serve fails doctor Loop proposers and is refused by the producer before a cycle opens. Details: self-evolving.md § Your own proposer.
  • Every terminal round teaches, as a row. loop_lessons ((proposal_id, outcome) unique, (target_kind, target_id, created_at) indexed) is written by the settle phase for every terminal that teaches — validated | regressed | unvalidated | skipped | failed — in one write-lesson step, from buildLoopLesson (no LLM): hypothesis (the round’s summary; a proposer pipeline’s own hypothesis), files (the suggestions’ targets plus requested skill dirs; a proposer’s files[].path), failure_category off the cycle’s baseline evaluation, deltas from the watch’s watch_verdict.perMetric where it measured anything, else the round’s check_verdict (under repair the watch measures nothing, so its lessons read the check’s). A terminal with no round writes none. The same step moves helpful / harmful in SQL on the round’s cited_lessons, and only behind an insert that actually landed, so a re-run step cannot double-credit; helpful only when the validated verdict was measured (strength paired / measured), harmful on every regression. Recall is recallLessons over findLessonRecordsForTarget — each lesson with its round and cycle, so it carries its proposer, rejection and strength — what a person turned down first, then newest first, bounded by loop.lessons.maxRecalled after that ranking; expiry is one dated DELETE on the scheduler’s lesson sweep (CycleWakeReconciler.sweepLessons, every 5 minutes) at the BRAIN tier (loop.lessons.retentionDays). d.writeMemory / d.searchMemory are gone from the loop — the dashboard’s cycle story (Learn, “It remembered”) reads the table.
  • A human’s idea is a queued row, bound by the cycle that takes it. loop_ideas (cycle_id UNIQUE → loop_cycles.id ON DELETE RESTRICT, CHECK taken-xor-withdrawn, partial queue index on (target_kind, target_id, created_at) where neither) holds what a person or an agent posted for one entity. The producer opens the entity’s next cycle on the oldest queued idea whoever asked, once the previous cycle has settled; the idea steps past window.ready, shouldOpen and the recurrence gate, never past enabled, the claim or anything downstream. Taking and withdrawing are two compare-and-sets on the idea’s row, so exactly one wins; the one way back to the queue is a cycle whose Accept LOST its entity (a sibling holding a lock group, or another cycle of the entity) — nothing was tested, so the settle hands the idea back at its old place (requeueIdeaOfCycle, in the same step as the skipped write); status is derived (withdrawn · queued · the bound cycle’s status), never stored. Every surface — the loop_create_idea / loop_cancel_idea tools (capability loop:analyze, hitl: true by default) and the /idea chat command — goes through postLoopIdea / withdrawLoopIdea, and the entry handler answers the idea’s author at the same terminals it writes a lesson for.
  • A person’s rejection, a verdict’s strength and a cycle’s grade are derived, never stored. A round set aside reads rejection: { kind: "human", by, note } when a person skipped it (the note is what the dashboard’s Skip asks for; a Telegram button carries none) and { kind: "timeout" } when nobody answered its card; the loop setting a round aside on its own call is neither. A shipped change’s verdict reads strength: paired (the pre-merge check passed and the watch measured), measured (the watch alone — a per-metric reading or repair’s count), blind (nothing measured it — the breaker’s “unmeasured”); only paired / measured credit a lesson helpful. A settled cycle reads a grade — Improvement, Tradeoff, Tie, Inconclusive or Regression — off its watch’s verdict record and that strength — a candidate the pre-merge check regressed, off the check’s record (self-evolving.md § The cycle’s grade); none where no verdict weighs the change.
  • The ledger is a tool, and its columns are the contract. loop_history (Evaluation, capability loop:read — safe, no grant) answers one row per cycle, newest first, filtered by targetKind / targetId / feature / since, bounded by limit and paged by cursor: the change, its merge and revert, the check and watch records, the strength, the rejection, the lesson. Call it from a pipeline with callTool; it runs live in a run the loop launched. Columns: self-evolving.md § Reading the history.
  • Supervision is two capabilities, a hold is a row. loop_hold (one entity, or one lock group by id; a reason is required) is loop:hold — hitl: false, because it only stops the loop, so an agent that sees trouble stops it without waiting on a person. What hands oversight back is loop:supervise (hitl: true by default — relax it in constitution/engine-capabilities.json, or grant it to the pipeline that supervises): loop_rearm (the dashboard’s Re-arm as a tool), loop_release, and loop_ack_failure (dismisses a terminal failure from the loop’s health surface; not a re-arm). A standing hold stops every way a new cycle opens — queued ideas wait — and changes nothing else: gates stay as declared, no card is raised, a cycle already running finishes. Holds, releases, re-arms and dismissals are kept with who (the Actor) and why.
  • An agent’s loop write is the dashboard’s write, minus oversight — one op. Every loop write (loop_join, loop_set, loop_widen, loop_hold, loop_release, loop_rearm, loop_analyze, loop_create_idea, loop_cancel_idea, loop_ack_failure) is the engine op the dashboard’s button runs, recorded as one action-log row against the entity it steers; loop_join / loop_set / loop_widen take dryRun and answer each file’s diff. loop_join (the dashboard’s Turn on) and loop_set (one knob at an entity or brain-wide) are loop:configure — hitl: false — and validate exactly as the dashboard’s editors do: resolved against every entity the write reaches, refused naming the entity it would break, nothing written. loop_set also refuses a write that widens oversight for any entity it reaches — autonomy, the change perimeter (editablePaths, protectedPaths, maxChangedLines) or the brain-wide loop switch turned back on — and one that takes an entity out of a lock group a person holds. The widening write is loop_widen, loop:autonomy (dangerous, hitl: true): a person approves it, and it answers what it widened. Every loop-settings write, from any process, takes one lock and commits only over the bytes it validated; a file that kept changing answers “try again”. loop_join switches a paused entity’s own enabled back on. The loop’s decide and retry cards and rating cards take no answer and no cancel from an agent ([unauthorized_responder]) — a grant request a loop agent stopped on is an ordinary grant card; the engine cannot tell an agent’s merge of a loop PR from a person’s, so under merge: human an agent must not merge one. loop:configure asks nobody and can spend (budget, analyses a day, sampling): set hitl: true on it in constitution/engine-capabilities.json to put a person there. Tools and refusals: self-evolving.md.
  • A person’s rating comes only from a person. A metric you declare kind: reported (people only) or judged with human: true (people over the judge) is rated on its scale through one write point, reached three ways: a rating card the engine posts to your HITL chat and the dashboard (evaluation.human.ask), the dashboard’s Rate runs (for a chat role, Rate on each of its episodes), and eval_rate — an agent relaying the rating of the person its turn serves (capability eval:rate, moderate, hitl: true by default, so that person confirms it). A rating of a run the sampler passed by books the judge on that run and lands on it. It refuses anyone who is not a person (an agent with no person behind its turn, a pipeline, the loop), a run the loop launched (a replay), a run that no longer exists or has not ended, a metric the entity does not have people rate, and an answer off the scale. No agent rates on its own account — yours or the loop’s. One rating per person per metric per run (their latest stands); the loop reads the median across people, and only where it reads your entity’s own runs (trigger, watch, the proposer’s brief, the dashboard) — the pre-merge check stays the judge’s.
  • Unattended turns raise no HITL. Under decide: auto the loop’s proposer and implementer run unattended: a grant request (or any other human-blocking request) from them is refused before a card exists, so a capability their role needs must be granted ahead of time — omnislash doctor names the ones that are not, under Loop autonomy.

The baseline clone and the per-run clones live outside /app/brain (runtime infrastructure, not brain content) — so they’re not G/R/W.

Your skills/ tree stays live even in a jail. A per-run clone is only as fresh as the last brain-sync tick, so a skill installed five minutes ago would not be in it. The engine therefore mounts the live skills tree read-only beside the checkout and points the catalog there — a skill you install is usable on the next turn, jailed or not. It is mounted outside the working tree on purpose, so it never shows up in the run’s git status and never lands in the run’s PR. An agent that authors a skill still writes into its own checkout at /app/brain/skills and commits it normally.

A role or skill edit reaches a live session on the next turn, on every harness. Each harness takes the system prompt through its own instruction channel — Claude as its system prompt, Codex as developer instructions, OpenCode as an injected instructions file — never pasted into the user turn, so a long session’s transcript holds no copy per turn. Codex fixes its developer instructions when a thread starts; when your edit (or a different dispatch, e.g. an interactive chat turn after a programmatic call) changes the prompt, the engine re-delivers it once at the top of the next turn’s message (<instructions-update>). A Codex step whose system prompt is above 128 KiB serialized fails and the chain fails over — keep roles well under that (the role-prompt budget, role-prompts.md, sits far below it).


2.4 Skill delivery — which skills an agent gets, and who offers them

Brain-dev thinks in skills, not in tools. A tool is an engine concept; a skill is a brain concept — a skill may drive tool calls through its instructions, but the brain author operates on skills.

A role gets the skills its skills: list names, and no others. The list is an allowlist, like capabilities::

skills: ['*']              # every eligible skill (also how the engine's broad roles ship)
skills: [github, notion]   # only these two
# no `skills:` (or [])     # no skills catalog, no harness `Skill` tool

'*' beside names collapses to ['*']. A name that no installed skill answers to lists nothing (omnislash doctor → Role declarations names it). A scenario’s skills: and the agent_task tool’s skills argument replace the task role’s list for that run (an empty scenario list means “keep the role’s”); a persistent chat agent has no per-call list.

Your skills reach an agent as a catalog the engine appends to its system prompt: a ## Skills heading, one sentence saying how to load a skill, then one line per skill — - <entry>: <description>. On a CLI agent the entry is the skill’s directory under the absolute skills root the sentence names (engine/scheduling, github) — a feature skill’s is its path under the brain root (features/coach/skills/drill), which the sentence then names as well — and the agent reads <root>/<entry>/SKILL.md when it decides to use one; on an API agent the entry is the skill’s name and it calls skill_get, so its catalog is sent only when its grant holds skill:read (or '*') — a catalog it could not open is pure cost. A brain skill that overrides an engine skill ends its line with (builds on …), naming where the engine copy is. skill_get, skill_read_resource and the MCP skill resource serve only enabled, eligible skills.

metadata.omnislash.listed: false in a skill’s frontmatter keeps it out of every '*' catalog; a role that names it in skills: still lists it. Use it for a specialist skill the '*' roles should not carry. The engine’s own agent-state (the state modelling guide, § 8) ships unlisted: the state module points at it instead.

Write the description triggers-first and keep it under 250 characters. The catalog is in every listed agent’s prompt on every turn, so the engine trims each entry to 250 chars at render time (the Agent Skills standard’s 1024 is a validation ceiling, not a target — 30 skills at the ceiling is a permanent ~8k-token tax). Trimming cuts the tail, so a description shaped “here is what this is… use when X” loses exactly the words that make an agent pick it. Put the trigger in the first sentence; the reasoning belongs in SKILL.md, which costs nothing until the skill fires. Skills installed from third-party sources are trimmed the same way and never break the catalog.

If the whole catalog still overruns its budget, the engine shortens every description equally, then falls back to the entries alone — dropping a skill is the last resort, never the first, because a skill the agent was never told about cannot be asked for. A role that names its skills instead of '*' keeps its catalog small to begin with.

A role picks who delivers what its list grants via promptFlags.skills in its frontmatter:

ValueEngine catalogThe CLI harness’s own Skill tool
engine (default)yesdenied
harnessnoavailable
bothyesavailable

There is no “off” value: omit skills: for no skills. A leftover promptFlags.skills: false fails the role file’s load with a message that says so.

The default is engine because everything in that catalog has passed your policy layer — the role’s list, .settings.json activation, eligibility, the dashboard. The harness’s own skill system is a parallel one you do not control: it offers the skills bundled with the CLI install plus .claude/skills/ from whatever repository the agent happens to be working in, which for a cloned third-party repo means its authors decide what your agent is offered. The names in your list do not filter that side — any non-empty list turns it on whole (doctor warns on a named list with harness / both). Choose harness or both when you want the bundled harness skills (document production and the like) and accept that. API agents have no harness, so harness means no skills there and both behaves as engine.

3. features/<name>/ convention — flat, all G

Each feature is one directory; the whole tree is G (volume-mounted, git-managed by brain-sync). No spec/code split.

features/<name>/
├── FEATURE.md                    ← optional: name, description, enabled switch, README body
├── <name>.scenario.md            ← scenario (entity-discovered)
├── <name>.blueprint.yml          ← the plan for it (planner-written, hand-editable)
├── <name>.pipeline.ts            ← primary pipeline (Restate service)
├── <name>-<suffix>.pipeline.ts   ← auxiliary pipelines (optional)
├── <hook>.hook.md                ← event hook (entity-discovered)
├── <hook>.hook.ts                ← its gate (optional code condition, found beside the hook)
├── <name>.heartbeat.md           ← heartbeat (entity-discovered)
├── <name>.heartbeat.ts           ← its gate (optional code condition, asked every tick before an agent)
├── <name>.context.md             ← feature context: what a turn linked to the feature carries (§ 9.8)
├── <name>.widget.html            ← widget page: an interactive page an agent opens for a person (§ 9.9)
├── <name>.widget.yml             ← its spec (optional: policy, tools, csp, permissions, inputSchema)
├── helpers.ts, fixtures/, README.md, ...
└── ... (brain-author owned, no engine constraint)

A feature is a top-level directory under features/; its id is the directory name, which must be an entity id (hidden dirs and node_modules/ are not features) and must not be engine — the id of the engine’s built-in feature (its system: roles), which no brain folder may take: the entities of a features/engine/ folder never load, fail boot like any misplaced file, and doctor Brain entities names the folder. Entity discovery is depth-agnostic inside a feature — *.scenario.md is found anywhere under features/<feature>/. An entity file directly in features/, in a directory whose name is not a feature id, or anywhere under constitution/ fails boot, listed by path. An entity’s id is its filename without the suffix, and it must be an entity id too (it starts with an ASCII letter, digit or _, then ASCII letters, digits, _ . -, no .. — so no :); a file whose id is not loads as nothing, reported by doctor Brain entities. Two files with the same id — even in different features — load neither. A scenario’s inline hooks (hooks: in its frontmatter) each declare an id: slug (ASCII letters, digits, _ -, no .), unique in the scenario; the hook’s id is <scenarioId>.<id>, and a hook without one fails the scenario’s load (doctor Hook ids fails on a standalone *.hook.md of the same id). FEATURE.md enabled: false switches every entity in the feature off without touching their files — each entity runs only when its own enabled AND its feature’s are true; switching off a feature with pipelines cancels their in-flight runs. Full rules: content-layout.md.

Entity kinds and refs. A feature ships four kinds of entity — scenario, heartbeat, hook and role (a *.agent.md; a system role is a role too) — and the engine names one the same way on every surface: kind:id, the kind, a colon, the entity’s id (scenario:daily-digest, role:system:loop:proposer). A ref splits on its FIRST colon, so a system role keeps its own colons in the id. The vocabulary ships as @omnislash-ai/types/entity (ENTITY_KINDS, EntityRef, formatEntityRef / parseEntityRef); how an entity is doing — off, ok, attention, failing, and how many of its executions are in flight beside the state — as @omnislash-ai/types/health.

The <name>.blueprint.yml beside a scenario is the plan the planner produced for it — phases, tasks, and the run’s deliverable: block. It is an ordinary file of yours: hand-edit it and the next run uses your edit, add a top-level locked: true and the planner stops regenerating it. What the deliverable block means is deliverables.md.

The evaluation: and loop: frontmatter blocks — carried by scenarios AND heartbeats, with brain-wide defaults in constitution/MANIFEST.yaml — are documented in self-evolving.md, including the one rule you cannot skip: anything the engine runs for you (a scenario with a schedule:, every heartbeat) owes an eval ceiling or goes ungraded.

Gates — the code half of a hook or a heartbeat. A gate is one primitive with two kinds: a file of code beside the definition — <hook>.hook.ts beside <hook>.hook.md, <name>.heartbeat.ts beside <name>.heartbeat.md — that decides, per fire or per tick, whether an agent runs at all and what it is a run of. Both answer the same verdict — false (no agent; the run ends skipped, reason gate), true (runs as declared), or { vars?, reach?, entity? } (runs with extra template variables, another reach, or about one entity key); anything else — undefined from a gate that forgot to return, an unknown key — fails the run. Both run under the same timeout seam (30 s; hooks.gate.timeoutMs / heartbeats.gate.timeoutMs in constitution/MANIFEST.yaml; gate: { timeoutMs } in the entity’s frontmatter), get ctx.signal (aborted at the deadline — hand it to your fetch / queries), ctx.reach, ctx.log, ctx.fence(text) (a var renders into the prompt exactly as returned, so text you did not write — a sender’s name, a PR title you fetched — goes into a var through it) and ctx.getServiceToken(providerId) (the live token of a provider you declare in oauthProviders, as a tool gets it), are re-imported by content (an edit reaches the next run, no restart — a helper file a gate imports does not reload), may read env and query db in their body like any brain code (the context deliberately carries neither — the provider token is on it because only the engine holds it), and never fail open: a gate that does not load, throws, times out or answers no verdict fails the run with no agent. Deleting the entity deletes its gate.

Hook gates. A hook’s filter: can only say “these fields equal these values” (a list value means any of these: authorAssociation: [OWNER, MEMBER]). Anything else — a keyword in the text, a contact list, the time of day, a row in your own tables — goes in a gate: <hook>.hook.ts beside <hook>.hook.md, default-exporting defineHookGate from @omnislash-ai/config/public:

// features/billing/run-failed.hook.ts — beside run-failed.hook.md (event: workflow:run.failed)
import { defineHookGate } from '@omnislash-ai/config/public';

export default defineHookGate<'workflow:run.failed'>(async (event, ctx) => {
  if (!event.slug.startsWith('billing-')) return false;   // no agent, fire ends as hook:skipped
  return { vars: { team: 'billing' } };                  // runs, {{team}} joins the template
});

It works for every event a hook can listen to; event is typed by that event’s schema. Return false to skip the fire, true to run it, { vars } to run it with extra template variables (they sit over the event’s own fields), { reach } to replace the contexts the fire reaches (ctx.reach is the hook’s own: its reach:, else its directory and above), { entity } (with or without vars / reach) to key the reach’s one keyed context — a reach with none or several fails the fire (contexts.md § 2.3). A var renders exactly as you return it — the event’s own sender-authored fields are fenced as untrusted for you, but text you copy out of them into a var is not: pass it through ctx.fence(text) first. Choosing the agent is not a verdict — the role is the hook’s frontmatter, and two roles are two hooks. The gate runs inside the durable, retried hook-trigger job, after filter: matched and before grants or the agent — so it may run more than once for one event: keep it a decision, and make any side effect idempotent. A gate that throws, times out, returns anything else or has no default-exported function fails the fire (hook:failed, no agent) — the hook never runs ungated. Timeout: 30 s by default, hooks.gate.timeoutMs in constitution/MANIFEST.yaml for the brain, gate: { timeoutMs } in the hook’s frontmatter for one hook; on expiry ctx.signal aborts, so hand it to your fetch/queries. The gate is re-imported by content on every fire (an edit reaches the next event, no restart — a helper file it imports does not reload), may read env in its body like any brain code (and a provider token through ctx.getServiceToken(providerId) — the one credential only the engine holds: the token of a provider you declare in defineProject({ oauthProviders }), refreshed when due, exactly as a tool gets it; an undeclared or unconnected provider throws, failing the fire), and is reported by doctor Hook gates when it does not load or has no hook beside it. Scenario-inline hooks take no gate — their frontmatter refuses gate:; give such a hook its own .hook.md if it needs one.

Heartbeat gates. A heartbeat whose work exists only on some ticks — “while no PR of the series is open”, “if today’s check-in is unanswered” — would otherwise pay a whole agent turn per idle tick to find that out. Put the condition in <name>.heartbeat.ts beside the .heartbeat.md, default-exporting defineHeartbeatGate from @omnislash-ai/config/public:

// features/site/website-improver.heartbeat.ts — run only while no PR of the series is open
import { defineHeartbeatGate } from '@omnislash-ai/config/public';
import { env } from '../../env.ts'; // your brain's typed env

export default defineHeartbeatGate(async (tick, ctx) => {
  if (tick.trigger === 'manual') return true;            // a person asked for it now
  const res = await fetch(
    'https://api.github.com/search/issues?q=repo:acme/site+is:pr+is:open+head:improver/',
    { headers: { authorization: `Bearer ${env.GH_TOKEN}` }, signal: ctx.signal }
  );
  if (!res.ok) throw new Error(`GitHub answered ${res.status}`); // the tick fails, never runs blind
  const { total_count } = (await res.json()) as { total_count: number };
  if (total_count > 0) ctx.log.info({ open: total_count }, 'a series PR is still open');
  return total_count === 0;
});
// features/coach/checkin.heartbeat.ts — run only while today's check-in is unanswered
import { defineHeartbeatGate } from '@omnislash-ai/config/public';
import { db, sql } from '@omnislash-ai/infra/drizzle';

export default defineHeartbeatGate(async () => {
  const { rows } = await db.execute<{ answered: boolean }>(
    sql`SELECT EXISTS (SELECT 1 FROM checkins WHERE day = current_date AND answered_at IS NOT NULL) AS answered`
  );
  return rows[0]?.answered ? false : { vars: { day: new Date().toISOString().slice(0, 10) } }; // fills {{day}}
});

tick is { scheduledAt, trigger } — the cron fire time the tick serves (null for a manual run) and 'cron' or 'manual': the gate is asked on a heartbeat_start too, and decides itself whether a person’s run passes. ctx.heartbeat is the heartbeat’s id, name and feature; ctx.getServiceToken(providerId) hands a declared OAuth provider’s token, as a hook gate’s does (a stop condition behind WHOOP or another connected API is still a gate’s). The gate runs after the scheduler brake and the switch, and before the tick’s agent, its grants or its turn exist. A no ends the tick skipped with reason gate (run_list / run_get show it apart from a brake or switch-off skip) and creates no agent row — no run history entry beyond the tick’s own row, no grade, no challenger for a loop cycle under validation; the fire still counts as served, so health never reads the heartbeat Late or Down for it. A pass with vars fills the body’s {{name}} placeholders (an unknown one stays as written; a body with no vars is sent exactly as written); { reach } / { entity } shape the tick’s reach as a hook gate’s do; a title or a body your gate fetched goes into a var as ctx.fence(title). A gate failure ends the tick failed with error.class: gate_error. A tick is tried once, so health counts it like any failed tick — Attention, then Failing after health.failingAfter in a row — rather than Failing at once as a hook’s gate error (written only after the fire’s retries) reads; a gate that fails on one flaky request does not mark the heartbeat broken. heartbeat_get answers the gate’s path and sha; doctor Heartbeat gates fails on a gate that does not load and warns on one with no .heartbeat.md beside it. The loop’s implementer may not edit a *.heartbeat.ts (the engine floor, self-evolving.md): a heartbeat is measured by its ticks, and its gate decides which ticks happen. The proposer reads it: the heartbeat’s definition it is handed names the gate’s path.

Per-chat launch limit. A scope: fire hook on an event that names a chat (the mtproto:message.* events) starts a fresh agent per fire — one per message in a busy group. At most chatLaunches.perMinute fires per hook per chat per minute launch; the rest end as hook:skipped (reason: rate). 6 by default, hooks.chatLaunches.perMinute in constitution/MANIFEST.yaml for the brain, chatLaunches: { perMinute } in the hook’s frontmatter for one hook. A scope: chat hook is not limited: its messages queue in one agent’s inbox.

Hook agent scope. By default every fire starts a fresh agent. agent: { scope: chat } in a hook’s frontmatter keeps ONE agent per chat the event happened in instead, and hands it every later event of that chat through its inbox — the conversation carries over. It is valid only on an event whose payload names a chat (account + chatId — the mtproto:message.* events); on any other event the hook does not load and doctor says why. Scenario-inline hooks refuse it (they only hear their own run). The chat agent acts as the MTProto account’s holder (tg:user:<id>); what it may do without an approval card (a reply is messaging:send, approval-gated by default) comes from the hook’s grants: block (§ 7.1), issued to that chat’s agent — one chat, never every chat the hook serves — and refreshed per the maxCalls caveat there. Its Telegram tools are held to that one chat of that one account — another chat, account-wide tools or another account are refused (CHAT_SCOPE). The hook’s agent: block is read when the chat’s agent is created (role and memory settings also ride each message); the agent lives until you delete it. Its text arrives fenced as untrusted content. Guide: telegram-account.md § One agent per chat.

Inbound GitHub events need no source of yours. With the deploy’s GitHub App registered, every PR, review, review comment, conversation comment, check, status, push and issue event it receives is recorded under the engine’s reserved source github as a typed github:<event>.<action> event and fires your hooks like any other — flat fields (repo lowercase, senderType, isOwnApp, authorAssociation, …), sender-written text fenced, bot = senderType: Bot. Names, fields and resource keys: integrations.md § GitHub events.

Resource watches — waiting on an external resource without polling. An event recorded with resource keys (every GitHub event carries github:pr:<owner>/<repo>#<n> and the like; your own source adds them through defineEventSource.extract → resources) reaches whatever watches that key. An agent subscribes itself with watch_create (a PR URL or a key): the events land in its inbox as ONE batched turn after a quiet period, fenced as untrusted content, as a cue to re-read the resource — never as its state; its own App’s echoes and untrusted authors are kept out (and counted, watch_list says why), checks on a superseded commit are dropped, and the watch ends with a final batch on its terminal event (a PR closing, by default), on its expiry, or on its delivery budget. A workflow task or a pipeline step’s agent cannot hold one — the pipeline waits durably itself with subscribeResource / waitForResource (pipelines.md). The watched agent is exempt from retention while the watch lives. How a watch behaves is § 9’s events.watch. For a GitHub PR the engine also records what GitHub never sends — github:checks.settled (every check on the head reached an outcome) and github:pull_request.mergeability (the PR began or stopped conflicting) — and pulls any delivery the receiver missed straight from GitHub (integrations.md § GitHub events). Guide: features.md § Events System (Resource Watches).

Inbound Telegram events are hook-driven, not configured. The worker listens to a Telegram user account only while some enabled hook on a mtproto:message.* event can match it (its filter: names that account, or no account at all), and keeps only the updates some such hook’s filter: accepts — there is no listener switch, and a hook added, edited or removed is picked up within ~30 s. Every event is stored before it is published and fires a hook at most once; restarts and reconnects catch up, and a stretch Telegram will not replay is published as mtproto:gap. Guide: telegram-account.md.

Per-feature pipelines. A scenario owns its *.pipeline.ts files by name, anywhere inside its feature — the directory layout is yours. The primary file is <scenarioId>.pipeline.ts with default-export name === scenarioId; auxiliaries are <scenarioId>-<suffix>.pipeline.ts; when several scenario ids of one feature match a file, the scenario named exactly after it owns it; otherwise a scenario that has its own <scenarioId>.pipeline.ts beats 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. One set of rules (content-layout.md § Composite scenarios (multi-pipeline)), checked by one loader in three places: the pipelines service at boot, on every hot reload, and omnislash doctor --check pipelines — the step your generated CI and deploy gates run, so a module that would not load is red before it reaches the host. Every broken module is listed, not just the first; what boot and hot reload do with one: § 5. A *.pipeline.ts no pipeline scenario owns is never registered — logged at boot and failed by omnislash doctor (Brain entities).

Reserved ctx.run step-name prefixes: db-*, emit-* and journal-*. Engine pipeline instrumentation uses these for its own bookkeeping and silently skips them from pipeline_steps, totalStepCount, and pipeline:step.* events — a real step named db-fetch or emit-report is invisible in the timeline/dashboard, so pick any other prefix.

An in-flight pipeline invocation finishes on the code and content deployed NOW, not on the ones it started with. The engine keeps one Restate deployment and re-registers it in place (Restate’s in-place update), so a suspended invocation replays its journal against the new code: the order, names and inputs of ctx operations and the result shape of every ctx.run are frozen for every run still in flight, and a change there fails replay with RT0016. The engine does not wait for such a run to wake: on every start (as it comes up) and hot reload (before the new code serves), it replays each live run’s journal against it (nothing executes), and a run the new code cannot replay is settled at once by its scenario’s onCodeChange — end (default: killed as run_kill kills it) or restart (reset as run_reset resets it — every step before the wait runs again; a keyed restate.workflow run and a run another run started are ended instead) — with the reason on the run and one message to the status chat, no card (§ 9, the onCodeChange row). A run the check could not decide pauses at wake, never vanishes — and it is listed, with its service, key and failure and the tool that ends it, by runtime_overview (paused) and the runtime doctor’s Paused invocations (doctor_check, the dashboard’s System › Doctor); it is repaired by a journal-compatible fix + run_resume, or reset by run_reset (kill, then the same handler again on its full stored input, linked as rerunOf; a Restate workflow-kind run: run_kill + scenario_start under a new key). What is safe to change, what engine helpers journal for you, retry-policy overrides: pipelines.md § Editing a .pipeline.ts with in-flight invocations.

Adding a new feature. mkdir features/<name> + add <name>.scenario.md and (optionally) <name>.pipeline.ts. Commit and push — brain-sync pulls on the next tick, the pipelines app rebuilds, the new Restate service registers. No image rebuild required unless the pipeline imports a new npm dep (then bump brain package.json and rebuild).


4. pnpm-lock.yaml — committed for local installs, NOT used by the image build

The lockfile is committed. .gitignore must NOT list pnpm-lock.yaml. It serves local installs only (your machine, IDE typechecks, deterministic re-installs after a git pull) — it is not consumed by the Docker image.

Generate it by running pnpm install from the brain root. Resolutions for @omnislash-ai/* come from the npm registry, not from a workspace. No sibling clone, no rewrite, no symlink dance.

At image build, the Dockerfile:

  1. COPY brain/package.json into /app/brain/. Does NOT copy brain’s pnpm-lock.yaml — the image is decoupled from your local lockfile.
  2. Adds - brain to /app/pnpm-workspace.yaml (idempotent).
  3. Runs pnpm install --filter=<brain-name> from /app/. The image uses engine’s /app/pnpm-lock.yaml (frozen for engine deps) plus a fresh resolve for any brain-only deps not in engine’s lockfile. @omnislash-ai/* peers resolve to the workspace member.

Trade-off. Brain-only deps (e.g. zod) re-resolve fresh on each image build; with caret pins, drift is at most patch-level between rebuilds. Pin exact versions in package.json if you need full reproducibility — the image install still satisfies the pin.

Why decouple? Baking brain’s lockfile into the image would require it to encode workspace-shaped importer paths (../packages/<name>), only producible by adding engine source as a sibling of the brain repo at install time — which doesn’t generalize to external brain authors who don’t have engine source. Decoupling collapses the two install contexts (local + image) into independent flows that share only the package.json shape and the engine lockfile baked into the base image.


5. Enforcement (engine-side — you write nothing for this)

§ 1–4 define what you declare and structure. § 5 is what the runtime does to keep that honest; a brain author never writes anything to satisfy it. Singleton enforcement is layered:

  1. Install-time (primary): strict-peer-dependencies=true fails the install when a peer pin and the engine workspace member disagree. Image build and local install both abort with a clear ERR_PNPM_PEER_DEP_ISSUES. Mismatches never reach runtime.
  2. Install-time (structural): omnislash doctor’s Brain package.json check fails on any @omnislash-ai/* key outside peerDependencies — the gap strict-peer leaves (regular deps aren’t peers). Runs via omnislash build / omnislash start.
  3. Boot-time (backstop, production only): the engine inspects $BRAIN_ROOT/node_modules/@omnislash-ai/* at startup and fails if any entry resolves back inside the brain root — i.e. the brain shipped its own physical copy of a shared package, splitting its module-level state. Last line of defense for the residual case where both install-time guards were bypassed.

The boot probe fires only under NODE_ENV=production (local trees resolve @omnislash-ai/* from arbitrary paths). A duplicate-copy regression therefore surfaces only after deploy — run brain-dev under NODE_ENV=production to catch it earlier. If it fires, it names the offending package: audit brain/package.json for an @omnislash-ai/* entry outside peerDependencies.

A pipeline module that does not load is caught in three layers, the same rules each time (§ 3 Per-feature pipelines):

  1. Gate (before the host): omnislash doctor --check pipelines in the generated ci.yml and in deploy.yml’s gates job — a red check blocks the deploy.
  2. Hot reload — never takes a serving scenario offline: a reload in which a module of a scenario that serves now does not load keeps the previous build serving; a module that does not load in a scenario not served anyway (broken since boot, or new) costs only that scenario, and the rest of the reload goes live. doctor_check / the dashboard’s System › Doctor → Pipeline build goes red naming each module and its error, and the Pipeline Build Failed alert fires after 5 minutes. Fix or revert and push: a fixed *.pipeline.ts reloads and clears it; a fixed helper clears at the next pipelines restart (§ 2.1 case 2).
  3. Boot — partial: the pipelines service serves every scenario that loaded (chat and workflows with them) and reports the rest the same way. A restart is therefore safe for the brain, not for the broken scenario: it is not served — its last good build is gone — until the fix lands. A brain module that crashes the process at runtime (an unhandled rejection, OOM) still takes chat and workflows down with it: they share the process.

6. Gotchas

  • Starting a new brain → omnislash init <dir> (see getting-started.md), not a copy of brain-example/. The scaffolder emits a registry-pinned, standalone-installable brain in the § 1 peer shape. brain-example/ is an engine-internal fixture: its peerDependencies: { "@omnislash-ai/*": "workspace:*" } only resolves inside the engine workspace, so a hand-copy breaks with ERR_PNPM_NO_MATCHING_VERSION on the first standalone pnpm install.
  • omnislash.config.ts must never import env. The config file is committed and public; env carries secrets. Mixing them leaks secrets to git. No linter enforces this yet — be deliberate. Read private values via env.* in your tools/ / brain code, not in the config file.
  • A missing skills/.settings.json does NOT disable your skills. It holds explicit enable/disable overrides only; a skill with no entry runs on its frontmatter defaultEnabled (omitted = enabled). No post-deploy skills enable --all step — the command remains only for stamping explicit entries.
  • Bump the engine peer pin in lockstep with deploys. The image tag follows peerDependencies["@omnislash-ai/cli"]; if you skip the bump, the image stays on the old tag while your code may already import a new API. omnislash upgrade moves it for you.
  • Brain-only deps re-resolve on every image build (§ 4). Caret pins → patch-level drift is possible between rebuilds; pin exact if that matters.
  • System messages come from the chief’s bot. Status notices, the owner and declared notification channels, approval cards with no chat of their own, a scenario chatbot.surface that names no bot, restart notices and Grafana’s alerts (with monitoring on) are sent by the bot whose role is chief.role (a bot that omits role is that bot). The token var it reads does not matter: TELEGRAM_BOT_TOKEN is required only when a bot’s tokenEnv names it. If every bot names another role, those messages have no bot to send them. They are dropped (a declared channel fails the boot, and a production deploy ships Grafana’s alerts to the webhook only), the personas keep chatting, and the doctor’s System messages check fails. One exception: what an agent of a persona role (a role a bot runs that is not chief.role) writes to the primary owner with no chat of its own — the owner channel, voice_send, an approval card — is sent by that role’s own bot into its DM with the primary owner, so the reply reaches the persona.
  • A compose override can shadow an engine-managed volume. If you add docker/compose.{mode}.yml (brain-dev-guide.md § Production troubleshooting), make sure it doesn’t omit a service that relies on an engine-managed anonymous volume — there’s no engine-side validation for that yet.

7. Constitution JSON overrides — capabilities & tools

Two optional JSON files in constitution/ reshape the engine’s tool plane without touching engine code. Both are G-set (hot-synced; edits apply on the next content-watcher tick, no restart).

constitution/engine-capabilities.json — override risk / hitl / description per engine capability. Governs policy for an action (e.g. make email:send stop requiring human approval). A capability the engine renamed fails the load, naming what it became; any other unknown one is warned and skipped. omnislash regen (and every omnislash upgrade, which runs it) keeps the file in sync with the engine’s capability set — adding new capabilities, dropping retired ones and preserving your overrides, a renamed capability’s risk / hitl carried onto its new name so a gate you raised survives the rename; omnislash doctor only reports whether it is in sync. If the file no longer validates — a misspelled risk, a missing hitl — regen leaves it exactly as you wrote it and says which field is wrong, rather than replacing it with engine defaults and quietly dropping every gate you raised. Fix the field, or delete the file and re-run omnislash regen to start from the engine defaults again. New brain-specific capabilities go in constitution/project-capabilities.json instead.

{ "email": { "send": { "risk": "moderate", "hitl": false, "description": "..." } } }

constitution/engine-tools.json — turn engine tools on or off entirely, per tool, per category, or wholesale. Governs whether a tool exists for your agents. Three layers, most specific wins: tools[name] → categories[tool.category] → default → engine default:

// allowlist mode — everything off except what you name:
{
  "default": false,
  "categories": { "Agent": true, "Memory": true, "Telegram": true },
  "tools": { "web_search": true }
}

// denylist mode — engine defaults except what you name:
{
  "categories": { "N8N": false, "Figma": false },
  "tools": { "queue_create_job": true }
}
  • tools keys are exact tool names, categories keys are exact tool categories (shown on the dashboard’s Permissions › Tools), default is the baseline for everything unmatched. Unmatched tools keep the engine default (most ship enabled; a few, like queue_create_job, ship disabled).
  • The policy governs engine and integration tools only. Your own project tools are exempt — you govern them in code (createTool({ enabled }), or simply don’t register them), so an allowlist ("default": false) never disables your own tools. A key naming a project tool is warn-logged and has no effect.
  • A disabled tool is fully invisible: it vanishes from every agent tool set AND a call by name is rejected as unknown_tool (MCP, webhook, API agents alike). When you want a tool the webhook door serves but no agent should see — an endpoint a paired device or an external service polls — that is createTool({ agents: false }), not enabled: false: it keeps the tool callable at POST /tool/<name> (with its schema, capabilities and metrics intact) while stripping it from every agent tool set and from the MCP tools/list. A machine-facing endpoint in a model’s context is noise at best.
  • A tool key must name a real tool. A renamed engine tool fails the load with the name it became; a key no tool answers to — neither an engine tool nor one your brain registers — fails the boot with the nearest real name (after a hot-synced edit the same is logged as an error, and the next boot fails). An unknown category is only warned about: an integration you have not configured has none. Renames: brain-dev-guide.md § Renamed engine names.
  • In allowlist mode, check what you are missing. A disabled tool is invisible everywhere by design — including on the dashboard’s Permissions › Tools, which shows only what is enabled. So a "default": false policy keeps denying every category the engine adds after you wrote the file, and nothing tells you. The doctor’s Tool policy check is where that shows up: it counts the registered tools your file switches off, names the categories, and says how many of them you never named at all (they fell through default). Read it after an engine upgrade.
  • The escape hatches get no special treatment here: disabling hitl_request_grant removes the agents’ grant-request hatch — and with it the only way an agent can ask for a tool its role does not list. Only do that deliberately. Which agents are OFFERED each hatch otherwise is § 7.4, not this file.

Op tools — the same answer the dashboard gives. Some engine tools are generated from the engine’s read/control ops, and the dashboard page that shows the same thing calls the same op, so the agent and the page cannot disagree: feature_list / feature_get / feature_read_file / feature_summary / feature_trend (capability feature:read), feature_set_enabled / feature_set_icon (feature:write — moderate, no approval by default; switching a feature off kills its live pipeline runs, which the dry run counts, so a brain that wants a person to confirm sets "hitl": true on feature.write in engine-capabilities.json), kpi_list, credential_list, provider_list, provider_get, provider_history, git_overview (the deploy’s GitHub App, the accounts it is installed on, and which lane authenticates each repository and why), git_history (the brain’s commits, newest first — of a feature, an entity’s files or given paths, inside since / until — each tagged person, evolution (a self-evolving loop PR), brain-sync (the runtime’s own writes) or engine, tags keeping only some), git_get_commit (one commit whole: its message and each file it changed with its unified diff, a binary or very large file named with its counts only), brain_search (free-text search across runs, agents, scenarios, blueprints, skills, pipeline runs, cycles and artifacts, and — when the role also holds hitl:read, governance:read or access:read — requests, capabilities and tools, bots, chats and people — the dashboard’s Cmd+K; under runtime:read it shows a role the ids and titles of runs, loop cycles and artifacts whose own tools need run:read / loop:read, so granting runtime:read grants that) and doctor_check (runtime:read — the doctor’s tool returns the report the dashboard’s System › Doctor renders: counts, then every check by category, a flagged one with its hint and maybe a fix.command; only: "issues" keeps just the warn/fail results under the whole run’s counts, the cheap triage read; every result carries the check’s stable id, and check takes one id (llm-providers) or the name the report shows and runs only that check — the same ids omnislash doctor --check <id> takes for the checks both run; its categories are Configuration, Infrastructure and Evolution, the self-evolving loop’s readiness), pipeline_build_state (pipeline:read — whether the pipelines endpoint’s newest build serves every pipeline scenario; unknown means no record could be read, never healthy), tool_list / tool_get / tool_summary (governance:read — the tool catalogue: every tool with what brings it, its capabilities and gate, and the engine tools engine-tools.json switches off; one tool’s schemas; how tools were called over 1h / 24h / 7d from Prometheus — its sections null, never zero, when it does not answer — with calls by agent beside it), audit_list (governance:read — the action log: who changed or decided what, newest first, filterable by actor kind, person, objects (targets), object kinds, feature, ops (action: ops or <namespace>.* patterns), a window (default all) and q; the next page passes the previous nextCursor as cursor), usage_summary / usage_breakdown / usage_trend / usage_heatmap / usage_tools (usage:read — model spend from the ledger, the same numbers the dashboard’s Audit › Cost draws: totals vs the window before and the month’s projection; spend grouped by up to five of role, entity (each row naming its feature), agent, conversation, execution, source, subject, kind, executionStatus, evalOutcome, trigger, model, provider, router, plan, billing, top-N plus other; per hour / day / week / month, at most 1000 buckets an answer (a longer axis is refused, naming the finest bucket that fits); by weekday × hour; tool calls per name — “spend by role this week” is one usage_breakdown call with window: "7d", by: "role"; every period read takes one window shape: window (1h, 24h, today, yesterday, 7d, 30d, 90d, mtd, all) or wall-clock from / to (YYYY-MM-DDTHH:mm) with an optional tz; billed (realCostUsd) and subscription (phantomCostUsd) spend are always apart), role_update / role_create (role:write — edit or create a role file: body, model, skills, memory, prompt budget, a narrower grant — never its money budget:, which is budget_update’s; a system: role is written into your brain’s copy, the first write copying the engine’s template), role_widen (role:widen — dangerous, a person approves: the same write when it ADDS capabilities or MCP servers to a role, or moves it onto a model whose command-line agent (Codex, OpenCode) keeps its own shell, file and web tools whatever the grant says; role_update and role_create refuse that, since every live agent of the role holds the new grant from its next turn), capability_set_policy / capability_delete_policy / capability_write_file (governance:write — restricted, approval by default: these write the capability policy itself — risk / hitl / description in engine-capabilities.json and project-capabilities.json — and a write can remove any approval gate, this one included; capability_set_policy / capability_delete_policy answer consequences.widening — true when the write lets calls through without a person — on the dry run as on the commit; capability_get / capability_read_file under governance:read read one capability in full and a policy file as written), grant_create / grant_extend / grant_revoke (the same governance:write — issue, extend or end a standing grant for a scenario, heartbeat, hook, role or session; an agent never grants an admin-only capability, governance:write itself or a person’s whole principal, gives at most 720 h, and leaves a Boss run’s own grant to boss_stop — grant_get / grant_list_declarations under governance:read read what stands), manifest_get (config:read — MANIFEST.yaml read back: each section as the engine uses it (values), only what the file itself sets (declared — a key absent there is the engine’s default), and the sha manifest_update takes), and model_update, eval_write_method, manifest_update (config:write — moderate, no approval by default, like file_write, which reaches the same files raw: models.json, constitution/Evaluation/Methods/<name>.md, MANIFEST.yaml; each is validated as the runtime loads it, and manifest_update sets only the keys its nested patch names, keeping every comment, and removes the keys its unset names (the engine’s default answers again) — it refuses the loop / evaluation / chat sections, which are loop_set’s, the audit / usage retention windows, which are manifest_set_retention’s (governance:write, approval by default: a shorter window deletes the action log), and the budget section, which is budget_update’s (§ 9.7)). Neither config write moves a role onto a Codex / OpenCode harness that ignores its grant: model_update refuses a write under which an existing model name (a role, an alias or cli:<role>) resolves there — model_widen (role:widen, a person approves) is the same write without that refusal — and manifest_update refuses a memory.consolidation model that moves the consolidator there (set it on the consolidator’s role file with role_widen). Role files are read the same way: role_list / role_get / role_read_file (feature:read — the file as written with its sha). Heartbeats and hooks are read and written the same way: heartbeat_get / hook_get and heartbeat_read_file / hook_read_file (the whole file as written with its sha; an inline hook has none — read its scenario’s) (feature:read), heartbeat_update / hook_update (typed keys) and heartbeat_write_file / hook_write_file (the whole file, validated as the loader reads it) under feature:write, which refuse a change that adds to what its runs are granted — its declared grants: (a capability added to the entity or to one of its inline hooks, even one another hook already holds, or a declaration’s ttlSeconds / maxCalls raised or removed), an agent: switched to a role granting more capabilities or MCP servers, or an agent: { modelRef } moved onto a Codex / OpenCode model whose command-line agent keeps its own tools whatever the grant says — heartbeat_widen / hook_widen (feature:widen, a person approves) are the same write without that refusal — and refuse a change to budget or evaluation, the raw write included (their own tools write them). heartbeat_start runs a tick now and hook_start test-fires a hook (run:execute; dryRun: true runs only the hook’s filter, on a payload you give or the event’s sample); event_list (event:read) is the event catalogue a hook’s event: names, and event_tail (the same event:read) the events themselves as they went over the bus, newest first, by name and since, a page at a time — a capped buffer of the last 20,000, never a record (tailFrom says where it begins); it carries every payload on the bus — every chat’s Telegram messages included — so a turn answering one chat (a secretary’s counterpart, a chat-scoped hook) is refused, and event:read belongs only to roles that serve owners. The deployment’s own observation: log_overview / log_list (runtime:read) read the container logs Loki holds — the services and levels there are, how long Loki keeps a line (retention: a window older than it reads empty, not quiet), then the lines of a window by service, level, run / agent / pipeline-run / task id and a literal search, newest first, polled (ask again with from moved for what came since; precondition where no Loki runs, as under omnislash dev); config_get (config:read) is the configuration as the engine runs it — every MANIFEST key and every data section of omnislash.config.ts with the tier it came from (engine default or brain), and every environment variable the engine and the brain declare with whether it is set — never a secret value: a secret variable answers by presence alone, and its current value is scrubbed out of every string the answer carries. system_stop / system_resume (system:stop — restricted, a person approves, granted to no role but a '*' one) are the emergency stop: one command brakes the scheduler (scheduler.enabled: false), pauses every queued, running or retrying workflow and pipeline run — and one that needs you while its other tasks still work (resumed, it waits on you again) — and cancels every agent turn in flight (the caller’s own excepted, the input each took dropped) — chat messages, hooks and scheduled prompts still start turns —, recorded as one action; system_resume undoes exactly what that stop did (the brake comes off only if the stop set it, the runs it paused resume, cancelled turns are not re-run; a run that will not resume is named stillPaused and the stop stands for it alone until a resume brings it back); system_status (runtime:read) says whether a stop stands. The dashboard’s own click on either needs no approval. What is in the brain’s own tables and key-value store: db_overview (db:read) lists every table and view of public largest first — estimated rows (off Postgres’ statistics, never counted), size, and the rows inserted / updated / deleted since the statistics reset — and the key-value scopes with their live keys and stored size, with the totals and the whole database’s size; db_tail (db:read) is one of those tables’ columns and latest rows (newest by created_at / updated_at when it has one, every cell as text, cut at 500 characters), a name that is no table or view of public not_found; kv_list (db:read) is one scope’s keys without their values (size, written, expires; at most 200, truncated) and kv_get reads a value, as it always did. All four are read-only — Postgres’ own read-only transaction under the read timeout. Scenarios and their plans too: scenario_get / scenario_read_file (feature:read — the switch, the engine, the files it owns, the plan’s state), scenario_update / scenario_write_file / scenario_delete (feature:write; scenario_write_file with path writes its .blueprint.yml or a *.pipeline.ts) with scenario_widen (feature:widen) the same way — the same budget / evaluation refusal, raw write included; switching a pipeline scenario off or deleting it kills its live runs, which the dry run counts (liveRunsKilled). scenario_start (run:execute) starts a scenario on whichever engine it runs on (a pipeline scenario’s primary service, or with service an auxiliary <id>-<suffix> one of its own *.pipeline.ts registers); scenario_plan (planning:write) previews a plan (dryRun: true) or queues the scenario’s planning (commit); blueprint_get / blueprint_list (planning:read) and blueprint_write / blueprint_set_lock / blueprint_delete (planning:write) read and write the plan file — a raw YAML save locks it, so the planner never regenerates over a hand edit. The money-budget ops — budget_status, budget_update, budget_widen, budget_raise — are § 9.7’s. Every command that commits — from the dashboard, an agent’s tool or MCP; never a dry run or a write that changed nothing — is one row of that log, attributed to its caller; so is every HITL card answered in Telegram and every accept or revert the self-evolving loop takes on its own. Their names follow the one convention (brain-dev-guide.md § Renamed engine names); a write to a brain file is dry-run → confirm → compare-and-swap: dryRun: true returns the diff and its consequences with the file’s sha, the commit passes it back as expectedSha, and a file someone else changed in between is refused (conflict), never overwritten.

chat_send puts words in the owner’s DM — its own capability, chat:send. chat_get (runtime:read) says where the chief’s DM with the primary owner is; chat_send (chat:send — moderate, never on the webhook door) sends a message into it as its caller: a user turn of that chat, mirrored to the owner’s Telegram, its first line naming the sender (Dashboard for the dock, else the agent or MCP client that sent it), with the objects it is about as refs the chief reads through its own tools ({ kind, id }; a query: view ref may name only a query op, with an input it takes). A chief role lacking the capability behind a ref’s reader tool sees the ref but cannot open it. It is the dashboard’s chat dock and an MCP client’s way to talk to the chief. Every engine role granted '*' holds it (the chief, general, coder, planner, …), so by default an agent’s or MCP client’s send waits for a person to confirm it; the dashboard dock never asks. Set "hitl": false on chat.send in engine-capabilities.json only when every role holding it should speak in the owner’s DM unattended.

7.1 What one approval buys — grant scope

When you tap Approve on a capability card, the engine writes a grant — and a grant does two things for as long as it lives: it authorizes the capabilities that need approval, and it adds every capability it carries to the agents it covers, so a tool their role never listed is offered to them from their next step on (§ 7.3). It is attached to exactly one thing. You never choose it; it is derived from the context the run already carries, by this precedence:

The run acts forGrant is scoped toDefault lifetime
a heartbeat (*.heartbeat.md)that heartbeat definition30 days
a scenario (workflow / scheduled plan / pipeline)that scenario24 hours
a hook (*.hook.md) — any agent its fire started (default agent.scope: fire)that hook definition30 days
an agent — a plain chat or MCP agent turn, or a chat-scoped hook’s (agent.scope: chat) agentthat single agent24 hours
none of the above — a bare principal (the MCP bearer door)that principal — standing, covers every session of the key30 days

The heartbeat row is the one worth knowing about. A heartbeat mints a fresh agent on every tick, so an agent-scoped grant would die with the tick that earned it and you would approve the same card every morning. Scoping to the heartbeat definition instead means one approval covers every future tick of that heartbeat until it expires — and only that heartbeat: two heartbeats asking for the same capability are two separate decisions. A hook is the same case: each fire starts a fresh agent, so one approval covers every future fire of that hook. A hook with agent: { scope: chat } is the exception: its agent lives on for the chat, so a grant there covers that one chat’s agent — never every chat the hook serves, which would hand a stranger’s chat what you approved in another.

One more scope exists that no card ever picks: a role (*.agent.md). A role grant covers every agent running as that role, in every session — strangers’ chats included — so the engine never mints one from a request: only grant_create issues one by name — a person on the dashboard, or an agent holding governance:write once a person approved its card (human-input.md). It covers agents only: pipeline code that speaks as a role (callTool(ctx, name, args, { role }), pipelines.md) keeps its scenario’s grants, so naming a role never widens a pipeline.

Consequences worth planning around:

  • Renaming a *.heartbeat.md or *.hook.md file resets its grants. The scope id is the definition’s id, so a rename reads as a different heartbeat or hook and the next fire asks again. Rename deliberately.
  • A grant is never widened after the fact. If a chat agent earned a 24h agent-scoped grant, a later heartbeat tick does not inherit it. Approve once from the heartbeat’s own card to get the standing grant.
  • Revoking is per-grant, not per-capability. Revoke the heartbeat’s grant in the dashboard and the next tick asks again immediately.
  • The card tells you which one you are getting. A standing (heartbeat) request is labelled as such and shows its 30-day TTL; you can override the TTL on the card via the custom flow.
  • Nothing ambient is granted past the requester’s own context. A run carrying a heartbeat/scenario/hook/agent axis can never earn a principal-wide grant. The one context that mints a principal-wide (standing) grant is a bare principal — an MCP bearer-door session, which has nothing narrower — and its card says so explicitly. Every principal-wide grant is an explicit human yes: that card, or a person acting in the dashboard.
  • An approval is how an agent gets a tool it lacks. hitl_request_grant can name any capability in your catalog, held or not: a chat agent whose role has no shell can ask for engineering:execute, you approve, and it has Bash for the grant’s lifetime. Only that agent (or that heartbeat / scenario / hook — the table above) gains it; the role file and every other agent of the role are unchanged. To give it to every agent of the role without a card, put it in the role’s capabilities: (§ 7.3). Administrator-only capabilities (loop:auto_accept) are never granted by a card. A grant authorizes a tool; it never turns one on: a capability whose every tool engine-tools.json switches off is refused before a card is raised, naming the tools — enable them there instead. A capability that opens a Claude builtin (engineering:execute, file:write, research:read, harness:subagent) is always asked: its builtins ignore engine-tools.json. An MCP session’s grant (a standing subject grant) authorizes its gated calls and adds no tool to its list, so it asks only for an approval-gated capability.
  • Delegation never buys what asking would. An agent that creates another (agent_create, agent_task) caps it at what it holds itself — its role plus its live grants: a role or capabilities argument beyond that is cut down, and the child asks for the rest with its own card. agent_update refuses to give another agent a capability the caller does not hold, a model whose harness ignores the grant, or a less confined runIn (sandbox → clone → live) — agent_widen (role:widen, a person approves) makes that change. A '*' agent (your chief, system:general, system:coder) is not capped; a person creating an agent (Telegram, dashboard, MCP) never is.
  • Taking another agent’s live run over asks a person. agent_intercept (and agent_release, which hands the agent back) carries capability agent:intercept — restricted, hitl: true, granted to no role by default: a role meant to step into workflow runs adds it, and every take-over it starts is a card a person approves. While a session is open, agent_send is how the interceptor talks to the agent.
  • Starting Boss mode asks a person. boss_start / boss_stop carry capability boss:execute — restricted, hitl: true, granted to no role by default: Boss mode hands an agent a person’s authority over the request queue until it expires, so a role meant to run it adds boss:execute (and boss:read for boss_status), and every start or stop it asks for is a card a person approves.
  • Only a person approves a card — agents cannot hand out capabilities. An agent holding hitl:write can answer another agent’s question or approval, and can deny a capability card, but approving one is refused [unauthorized_responder] and the card waits for you. The one exception is a running boss (boss-mode.md), and only for the capabilities you listed in its approvableCaps. Agents also cannot press the bot’s buttons through your Telegram account: telegram_press_button refuses the engine’s own bot messages.

Pre-authorizing an automation — the grants: block

An approval card blocks: a heartbeat tick holds its job for the whole human wait, and a workflow run sits in awaiting_approval. For a scheduled automation that is often a failed task rather than a delay. So a hook, a heartbeat or a scenario can declare its capabilities up front, in its own frontmatter:

---
schedule: "every 30 minutes"
grants:
  - capabilities: [infrastructure:discover]
    ttlSeconds: 21600    # optional, default 24h; min 4h, max 720h
    maxCalls: 20         # optional — read the caveat below before relying on it
---

No card is shown — the committed file is the approval, reviewed wherever you review your constitution. What it buys is exactly what tapping Approve on that automation’s card would buy — same scope (by the table above), and the same 720h ceiling on the TTL, so the two are interchangeable.

The block takes effect on every way the automation can start: a hook fire, a heartbeat tick, a workflow run, a /run that lands on the schedule gate, and a pipeline invocation.

  • This is the narrow tool. The other way to stop being asked is "hitl": false in constitution/engine-capabilities.json, which removes the gate for every agent in your brain, chat included. Declaring on the one automation that needs it is strictly narrower — prefer it.
  • Declare on the automation, not the role. There is no grants: on *.agent.md: a role is shared between a trusted cron and an ad-hoc chat, and authority that follows the persona would leak into the chat.
  • Some capabilities are never declarable. Anything that delegates human oversight itself (today loop:auto_accept — see self-evolving.md) can only be issued from the dashboard. Declaring it alongside valid capabilities is safe — the rest still apply — but it grants nothing by itself.
  • A capability that needs no approval is a no-op. Declaring fs:read grants nothing — the gate never asked. Usually it means you meant a different capability name.
  • ttlSeconds has a floor of 4h. Shorter than that and the row would already be outside the engine’s coverage horizon by the time anything looked for it: the automation would re-issue on every fire and still get the card. The loader rejects it so you find out at file load rather than months later.
  • maxCalls is a budget per refresh, not a total. A declared grant belongs to the heartbeat, hook or scenario that declares it and covers every fire; once its maxCalls are spent or its TTL nears the end, the next fire mints a fresh one with a fresh budget. A hook’s grant is shared by every fire; a chat-scoped hook’s (agent.scope: chat) belongs to each chat’s agent and is refreshed per chat, across that conversation. To bound a heartbeat or a hook, change its schedule or its filter:.
  • Editing the file is the only real kill switch. Removing the block lets the issued grant expire (within 24h, or its ttlSeconds). Revoking in the dashboard only closes the window until the next fire re-mints it — so revoke and edit.
  • Whoever can write your constitution can grant capabilities. The file is the approval, so if your agents can commit to constitution/, they can pre-authorize themselves. Gate that the way you gate any other write to your brain repo.
  • Check what you have declared: omnislash doctor → Declared grants lists every block in the constitution and flags admin-only, no-approval-needed, and duplicated capabilities; Capability names flags a name the catalog does not know in a grants: block or a role file (a .blueprint.yml’s are Blueprints’) — with the file and what it was renamed to.

7.2 Role frontmatter — context-window policy

Role files (constitution/Agents/system/role-*.md — a namespaced one in its subdirectory, loop/role-proposer.md — and your own *.agent.md roles) carry YAML frontmatter. One block there, context:, governs what happens as an agent’s context window fills up — and where the context report starts calling the role heavy:

# constitution/MANIFEST.yaml — every role
context:
  notifyAt: [300000, 500000]   # post "consider /compact" into the agent's chat
  compactAt: 700000            # compact the agent automatically
# features/support/triage.agent.md — this role only
---
name: Triage
context:
  compactAt: 400000            # notifyAt still comes from MANIFEST
---

Every key is optional; with no mark anywhere nothing fires. Mark values are absolute tokens, not fractions of the model window — a window is known only where a runtime reported it or constitution/models.json declares contextWindowTokens.

  • Where to put it. MANIFEST § context is the brain-wide value; a role’s own context: block overrides it key by key — a role that names compactAt keeps MANIFEST’s notifyAt. role-general.md’s block is the general role’s own, not every role’s default (since 1.21.0 — move a brain-wide mark from there to MANIFEST; doctor Context marks names a block left behind). A long-running chat assistant and a short-lived task agent usually want different numbers; that’s the seam.
  • The context report’s thresholds live in the same block, same tiers: prefixWarnShare (0.25) / prefixCriticalShare (0.4) — share of the window the prefix may fill before it is flagged; skillsHeavyShare (0.15) — share of the prefix the skills catalog may take; memoryHeavyTokens (8000); unusedToolsMin (5) — idle tools before “unused tools” fires; hotToolCallsPerRun (0.5) — calls per run at which role_trim proposes a deferred tool for toolLoading.eager. They only change what agent_context and role_trim report, never what an agent runs with. A service of the previous release refuses a key it does not know, so write them after every service runs 1.21.0.
  • Each mark fires once. The engine compares a turn’s context size against the previous turn’s and acts only when a mark is crossed. Sitting above a mark on later turns is silent, and a mark re-arms by itself after a compact or /clear. You don’t manage any state for this.
  • compactAt never kills a live turn. The agent queues the compact on itself at the end of the turn that crossed the mark, so it runs before the agent’s next turn and never cuts one short. It happens exactly once per crossing, with or without the bot service — a restart in between doesn’t lose it, and more bot replicas don’t repeat it. If the policy can’t be read at the end of a turn (say, a MANIFEST that fails to parse), that turn is simply not checked — the turn itself still finishes.
  • A pause doesn’t trigger it. An agent waiting on a HITL question is mid-turn, so the policy skips it and runs when that turn actually finishes.
  • Every harness is covered. Claude, Codex, OpenCode and API agents all measure each turn’s context, so the policy compares the same number on each. A Codex turn that compacted its own context and was not measured again afterwards is not checked; the next turn is.
  • Compacting by hand. agent.compact() queues one compaction turn on the agent — the same on API and CLI agents — that rewrites the history the engine owns and, on a Claude CLI agent, also compacts the CLI’s own session. With { wait: true } it tells you what happened on both axes: conversation is the history rewrite — compacted (with how many messages folded and the context size before/after), nothing_to_compact (with why), or failed (with the error) — and session is the CLI’s own compaction, not_applicable for an API agent or a CLI without one (Codex, OpenCode). Nothing is reported as success unless something was actually folded. While it runs, the agent’s output stream carries a context_compaction item so a chat UI can show progress instead of a frozen screen, and the final outcome rides the turn’s turn.completed event as compaction.
  • A chat /compact is a chat operation. agent.sendCompact(opts?) is the chat counterpart of compact(), the way send is of enqueue: the compaction joins the agent’s chat queue (the dashboard rail shows it) and runs when its turn comes — after the messages sent before it, before the ones sent after. { interrupt: true } cancels the in-flight turn and puts the compaction at the front of the queue (a message already about to be picked up in that instant can still run first). It returns as soon as the compaction is queued; the outcome arrives on the turn’s turn.completed as compaction. The Telegram /compact command, the /agents Compact button and the dashboard /compact all use it. Use compact() from code that is not a chat.
  • The two knobs are separate, and the plain call is the polite one. compact() disturbs nothing and returns { status: 'dispatched' } as soon as the turn is queued. Add { interrupt: true } when a human said “drop what you’re doing and compact” — it cancels the in-flight turn, the same knob enqueue / call / send take. Add { wait: true } when you need the outcome or your next step needs the folded context: it holds until the compaction turn finishes — without interrupt, that includes whatever the agent is running first. The two combine freely; neither implies the other.

The block is G-set like the rest of constitution/ — edit it and the next turn uses the new numbers, no rebuild, no restart.

What a fold actually keeps is a separate, project-wide knob in omnislash.config.ts:

conversation: { maxMessages: 20, summarizeTokenThreshold: 20_000 }  // defaults

summarizeTokenThreshold decides whether the automatic fold happens; maxMessages decides how much of the recent tail survives it verbatim. Everything older is replaced by a running summary and its rows are deleted — a fold is a prefix delete, not an archive, so folded detail is gone from the history for good.

Raise maxMessages and you owe the threshold a look. The kept tail is not subtracted from the next trigger’s token count, so a window wide enough to hold summarizeTokenThreshold tokens on its own will fold on every turn forever, each one an LLM call layering a retelling on a retelling. That is why the two live in one object.

Unlike the role block above, this one is read at startup — change it and redeploy. It applies to every surface that folds a conversation (chat, dashboard, MCP, and an agent’s own turn-finalize), not just the bot.

7.3 Role frontmatter — the capability grant

The same frontmatter carries capabilities: — which tools an agent of that role holds.

# constitution/Agents/system/role-memory.md
---
modelRef: fast
capabilities:
  - memory:read
  - memory:write
---

Two things about it are worth knowing before you write one.

The role file is the whole standing grant. What every agent of a role holds from the start is exactly that role file’s capabilities: list — '*' for every enabled tool in your catalog, a list for just those, and nothing declared (no block, or []) for none. Nothing is inherited: role-general.md’s list is the grant of the general role (agents created without a role — chats, heartbeats, hooks), not a floor under your other roles, so you can narrow any role to anything. Two consequences:

  • A role that should hold everything says so: capabilities: ['*']. The engine’s broad roles (role-general.md, role-chief.md, role-coder.md, …) all spell it out.
  • Code that spawns an agent may pass its own list; it is added to the role’s (a union — a '*' on either side means everything). A caller cannot narrow below what the role declares, so a role should declare what the job needs, not the widest case. An agent that spawns another (agent_create, agent_task) hands it at most what it holds itself — the child stays under that ceiling for life, whatever its role file later says.
  • On top of that, each turn adds every capability on a live grant that covers the agent (§ 7.1) — what a person approved for this agent, its heartbeat, hook or scenario, or issued for its role. That part is not pinned and not in any file: it appears when a card is approved and goes when the grant expires or is revoked.

Even '*' is not unattended reach: a capability marked hitl: true (in constitution/engine-capabilities.json) still waits for a person at call time.

The grant is the tool set, on every model. The same list — the role’s capabilities plus the live grants’ — decides which tools an agent is shown and may call, whether it runs on an API model or a CLI harness: on a CLI harness (Claude Code, Codex, OpenCode) the engine’s MCP server lists only the granted tools and refuses a call to any other, naming the capabilities the tool needs and telling the agent to ask for them with hitl_request_grant. The escape hatches are offered by their own rule (§ 7.4): AskUserQuestion whenever the turn is interactive, hitl_request_grant always, hitl_request_connection by its capability integration:execute. An agent can ask for a capability its role does not hold, and an approval adds it to that agent (§ 7.1); the prompt’s capabilities module lists what it could ask for, so it asks instead of making a person do the work. An execution nobody answers (an autonomous loop round, a replay, an engine job such as the evaluator or the memory curator) cannot raise the card — a heartbeat tick can, and its approval is the heartbeat’s for 30 days (§ 7.1): it reports which capability it was missing, and you give that ahead of time: in the role’s capabilities: for one it does not hold, and for one that needs approval a grants: block on the automation (§ 7.1) or hitl: false. Three things follow:

  • Give a role what it routinely calls. An agent can ask for a missing capability, but every ask is a card a person has to answer; declare what the job always needs. A role that calls hitl_request_approval needs hitl:request; one that uses the memory tools needs a memory: block AND memory:read / memory:write; one that mounts connection:-backed MCP servers or runs tools calling getServiceToken needs integration:execute to request a missing connection itself.
  • Every tool you register must provide a capability. A brain tool (or an integration-kit tool) with providesCapabilities: [] fails registration — no role’s capabilities: could select it, only '*'. Declare the capability it exercises in constitution/project-capabilities.json and list it. A name in providesCapabilities that is no capability — a typo, or one the engine renamed — fails registration the same way, naming the nearest real one or what it became.
  • state: needs the database grant. A role declaring state: { namespace } must hold db:read and db:write (or '*') — the state module tells the agent to use db_query / db_schema / db_execute. Without them the role file does not load, and omnislash doctor (Brain entities) names it.

Claude Code’s own built-in tools follow the grant too. A role on a Claude model (a bare claude, claude:<router>:<model>, or a role word that resolves there — cli:smart) is handed the harness built-ins its capabilities name (the role’s and its live grants’), and no others — on every turn, confined or not:

Built-insDelivered by
Read, Glob, Grep, TaskCreate / TaskGet / TaskUpdate / TaskList (the plan the dashboard streams)every Claude role
ToolSearch (how deferred MCP tools are reached)every Claude role on Anthropic’s own endpoint — not claude:<router>:<model>, where Claude Code has no tool search and loads every MCP tool up front
Edit, Write, NotebookEditfile:write
Bash, TaskStop, Monitor (a shell and its background work)engineering:execute
WebFetch, WebSearchresearch:read (cli.webSearch: false still removes WebSearch)
Agent (harness subagents — see below)harness:subagent
Skill (the harness’s own skills)promptFlags.skills: harness / both with a non-empty skills: list (§ 7.4)

'*' holds every row. Two kinds of subagent, two capabilities. A harness subagent (Agent, harness:subagent) is a helper inside one turn: the agent’s tools, model, checkout and spend, no agent row, gone when the turn ends — for parallel exploration or edits whose results come back to the agent. It gets the tools, not the role: no role body, memory or chat rules reach it, only the prompt the agent writes for it, so a rule a role’s helpers must keep belongs in that role’s instructions for briefing them. A harness subagent cannot ask people or request grants: it hands the question back in its result, and the agent asks. Only Claude Code’s built-in subagent types are accepted (general-purpose, Explore, Plan), and a per-call model or isolation is refused. A .claude/agents/ file in the checkout can still replace a built-in type of the same name, bringing its own MCP servers and model, so keep such files out of a brain whose agents hold harness:subagent. A confined turn (a loop proposal) never gets Agent. An engine agent (agent_task / agent_create, agent:execute) is an agent of its own: another role, model, grant or checkout. A Claude role holding harness:subagent is told in its turn-lifecycle prompt section how to brief its helpers, and, when it also holds agent:execute, when an engine agent is the right choice instead (absent under promptFlags.environment: false or a with-background-tasks.md override without {{subagents_bullet}}). Off for every role, whatever it declares: the legacy Task name, the native AskUserQuestion (the engine’s MCP one replaces it), Cron* / ScheduleWakeup, worktree and plan-mode tools, LSP, the MCP-resource browsers, and any built-in a future CLI adds — the list is an allowlist, so a new built-in is off until the engine names it. So a CLI role that runs a shell needs engineering:execute, one that edits files needs file:write, one that browses needs research:read — or its agents ask for it each time and wait for the card — including a role whose skills run a binary (requires.bins: the github skill runs gh); omnislash doctor (Role declarations) warns on a listed skill like that on a role without engineering:execute. Codex and OpenCode keep their own tools on an unconfined turn for now.

This shapes what the agent is given; it is not a sandbox. The authority layer is still the HITL gate above.

Upgrading from an engine where silence meant '*': a role that declared no capabilities: used to get every tool, and gets none now. That covers your own *.agent.md files and your copies of engine role files (a role-general.md override included). Add capabilities: ['*'] to every one of them that should keep everything — live agents of the role pick it up on their next turn.

The role file is read at every turn — nothing is pinned. Editing a role’s capabilities: or modelRef: reaches every live agent of that role on its next turn, narrowing included; no recreate, no SQL. What an agent keeps across such an edit are only its own overrides: a model chosen for it (a modelRef passed when it was created, /model in an owner’s DM, the dashboard’s edit form, agent_update; clear it and the agent follows the role’s model again), the capabilities added on top of the role’s (capabilities passed at create, an edit — never a narrowing), and the ceiling an agent-spawned child carries (an edit that adds capabilities above it is refused). Upgrading onto this engine, every existing agent follows its role’s model: a model set on one earlier (/model, the edit form) resets once — set it again. A chat others reach (a group, a business chat) runs exactly its role file: the chat clears both overrides on its next message, so a /model there does not stick. An owner’s own DM keeps /model. The model_ref / tools columns of omnislash.agents show what the agent resolved to at its last turn or edit — reading them is fine, writing them changes nothing.

Engine system roles ship their own grants (role-memory.md reads and writes memory, role-evaluator.md reads files, loop/role-proposer.md reads files, the database and the loop’s ledger (file:read, db:read, loop:read), loop/role-implementer.md reads and writes files — and the implementer’s turn compiles that grant plus the proposal’s protected surface into the CLI harness’s own permissions, so what the file says is what the turn can reach, role-editor-assistant.md (the dashboard editors’ assistant) is read-only; role-general.md, role-chief.md, role-coder.md, role-reviewer.md, role-researcher.md, role-planner.md, role-boss.md and role-git-conflict-resolver.md say '*' on purpose). Role ids are namespaced by subdirectory: each : after system: is one directory under constitution/Agents/system/, so system:loop:proposer is loop/role-proposer.md and system:loop:implementer is loop/role-implementer.md — override one by putting your copy at that same relative path (constitution/Agents/system/loop/role-proposer.md); a role-proposer.md directly under system/ is a different role (system:proposer). Overriding one of those files replaces it whole — drop the capabilities: block while customizing the prompt and the role is left with no capabilities at all. For the loop’s two roles widening is not silent either: when loop/role-proposer.md (the proposer, which works in your LIVE brain) or loop/role-implementer.md resolves to '*' or a shell (engineering:execute), omnislash doctor (Loop confinement) warns — a shell is a subprocess the protected surface cannot see, and the proposer’s also reads the judge’s files past the read wall. The loop runs with the grant you wrote; the warning is there so it is a decision, not an accident. The dashboard’s Evolution settings (/evolution?view=settings, Agent defaults) edit these three roles in place, and their first save materialises the whole engine template into your constitution/Agents/system/ before patching it — so an edit from there never drops capabilities: or the memory declaration — which makes the copy a fork: engine updates to that template no longer reach your brain. The same whole-file rule holds for memory: the loop’s three roles — loop/role-proposer.md (the proposer), loop/role-implementer.md (the implementer) and role-evaluator.md (the judge) — declare no memory: block, so none of them gets MEMORY.md, the daily logs or a recall over its prompt. Do not add one in your copy unless you mean it: omnislash doctor (Loop role memory) warns when the proposer or the implementer declares memory — a choice you may make knowingly — and fails when the judge does, because recalled memory is a part of the rubric nothing pins. A copy that still carries the old promptFlags: { memory: false } keeps meaning exactly that (the key is stripped; doctor Role declarations asks you to delete it).

/system/context in the dashboard prices this per role: a role that declares '*' on an API model is told to narrow it.

7.4 What a role is delivered — declared ⇒ delivered

A role pays only for what it declares. Each module below has exactly one declaration, '*' is the only “everything” and is always written out, and nothing is inherited from role-general.md.

ModuleDeclared byWhat you getWhen not declared
Memory — the memory prompt module, MEMORY.md + the daily window in the envelope, per-turn recall, the memory tools, the post-turn flush, the memory line on the wirea top-level memory: block on the role ({} counts — the brain’s MANIFEST.yaml budget), or agent.memory on the heartbeat / hook running itall of them, tuned by the block (write, read, recall, the budgets). The grant still decides WHICH memory tools: memory:read / memory:write / memory:edit / memory:deletenone of them: the agent never reads, recalls, writes or flushes any line, main included. A memoryFlush: true from the role, a caller, a heartbeat or a scenario is a no-op, and an agent_task child gets no memory even from a parent that has it. The CLI’s own memory (Claude auto-memory, Codex memories) is off for every role either way — the engine’s is the only one
Skills — the engine catalog, the harness Skill toola skills: list on the role (['*'] = every eligible skill except listed: false ones, names = only those); a scenario’s skills: or agent_task’s skills replaces it for that runthe catalog of the listed skills (on an API model only with skill:read or '*'); the harness Skill tool under promptFlags.skills: harness / bothno catalog and no Skill tool. skill_get still opens an enabled, eligible skill by name when the grant holds skill:read
Turn-lifecycle tool bullets (CLI) — “come back later → schedule_create_prompt”, “too long → agent_task / agent_create + agent_enqueue + agent_await”, and on Claude how to brief a harness subagent (Agent) and when an engine agent (agent_task) is the right one insteadschedule:write; agent:execute + runtime:read; harness:subagent, and for the contrast + agent:execute ('*' holds all)each bullet whose tools the grant can selectthe lifecycle sentence alone — a role is never told about a tool it is not offered
State modelling guide — one pointer line in the state module at the agent-state engine skillstate: (the module itself)the pointer — on CLI always, on an API model only with skill:read (or '*')the module’s own short rules, no pointer
AskUserQuestion + the question modulean interactive turn (the default)the tool and the question policypromptFlags.questions: false removes both, on every model; a non-interactive dispatch removes both too
hitl_request_grantnothing — offered to every agent (engine-tools.json can disable it)the hatch a missing_grant error and an outside-the-grant refusal both point at: any capability in your catalog, held or not; an approval authorizes it and adds it to the agent—
Capabilities module — the capabilities the agent could ask for, grouped by domain (on Claude, plus the built-in groups it lacks), and the rule to ask rather than hand the work to a personany role not holding '*', on a turn a person can answer (constitution/Agents/system/with-capabilities.md, overridable)every catalog capability the agent does not hold, minus administrator-only ones and memory:* on a role without memory'*' — nothing to ask for, no module; promptFlags.capabilities: false — the list is dropped (the request tool stays, and a refusal still names what to ask for); an execution nobody answers (an autonomous loop round, a replay, an engine job such as the evaluator) — the ask would be refused
hitl_request_connectionintegration:execute in capabilities: ('*' holds it)the agent can request a missing / dead connection itselfa missing connection fails with a message that says to authorize it from the dashboard
git_request_appintegration:execute in capabilities: ('*' holds it)the agent can ask a person — one card in the default HITL chat — to register the deploy’s GitHub App or install it on another account; the card closes itself when GitHub confirms the installationno agent can start it; every GitHub App remedy then leaves the dashboard’s Connections › Repos (/connections/repos) as the only path
Claude built-in tools — shell, file edits, web (§ 7.3)engineering:execute, file:write, research:read, harness:subagent in capabilities: ('*' holds all four)Bash / TaskStop / Monitor, Edit / Write / NotebookEdit, WebFetch / WebSearch, Agent — on top of the read, plan and ToolSearch built-ins every Claude role hasthat group is absent from the turn; Cron* and the other runtime-foreign built-ins are absent for every role

Worked example — a chat persona whose role is a body plus one capability (notification:send), no memory: block, no skills: list, no promptFlags: no memory module, envelope, recall or memory tools; no skills catalog; AskUserQuestion (it is interactive); hitl_request_grant and the capabilities module listing everything else it could ask for; no connect hatch. A role that lost memory by declaring none is not a fault — omnislash doctor (Role declarations) lists every role that “runs without memory” and warns on the declarations that cannot take effect: a flush with nothing to flush into, memory tools or memoryKeep without a block, a block without a memory tool, a leftover promptFlags.memory, a copy of an engine role made before the template declared memory or skills (a copy that writes skills: [] has decided “none” and is not named), a connection: MCP server without integration:execute, a skills: name no installed skill answers to, a listed skill that runs a binary (requires.bins) on a role without engineering:execute, a skills: list on an API role without skill:read, a named list with promptFlags.skills: harness / both, and (a fail) the flush curator system:memory without a block while memory.flush.enabled is on.

Upgrading from an engine where every role had memory and every skill: add memory: {} (or your existing budget block) to every role that should remember, and skills: ['*'] (or the names) to every role that should see skills — your *.agent.md files and your copies of engine role files (a role-general.md / role-chief.md override included; the engine’s own templates now declare both). A skills: list that used to only reorder the catalog now scopes it: review each one. Both are inert on the older engine, so ship them first. Delete every promptFlags.memory key and every promptFlags.skills: false in the same commit as the version bump.

Upgrading from an engine that called the chief rootAgent: rename rootAgent: to chief: in defineProject (the old key now fails the load with a message naming chief:), and move your copy of constitution/Agents/system/role-root-assistant.md to constitution/Agents/system/role-chief.md — the old path is no longer read. The engine role id is system:chief; system:root-assistant is not read (the upgrade’s migration moves the agent rows), and chief.name defaults to chief.

The runtime-observation domain is runtime. runtime:read is what the tools that observe the deployment carry — runtime_overview, health_overview, health_entity, health_list, doctor_check, watch_list, kpi_list, credential_list, provider_list, provider_get, provider_history, git_overview, git_history, git_get_commit, budget_status, brain_search, agent_context, role_trim, brief_digest, brief_status, notice_list, log_overview, log_list, system_status and the agent_get / agent_list / agent_summary / agent_history / agent_tail / agent_prompt / agent_export / agent_await read side. Spend reads are not among them: the usage_* tools carry usage:read; the one runtime read that shows money is budget_status, each limited scope’s spend against its ceiling. The word state is not theirs and never was: it is now reserved for an agent’s own Postgres state (§ 8, state.md), so the engine gave it up rather than let one word mean two things. omnislash regen reconciles constitution/engine-capabilities.json for you — the new capability in, the retired one out, your overrides preserved — but a role file, blueprint or grants: block that spells state:read by hand is yours to update to runtime:read; it is a plain string in your file, and nothing rewrites it. omnislash doctor → Capability names (roles, grants) and Blueprints (plans) name every such file — every engine rename is in brain-dev-guide.md § Renamed engine names.

7.5 A member’s turn — the chat’s capability ceiling

A role belongs to the chat, and one role often serves you in one chat and other people in another. The chat rule’s capabilities in constitution/access.yaml (chat-access.md § The member ceiling) is the ceiling of a non-owner’s turn: whatever the role and the live grants hold, a member’s turn reaches only those capabilities. Owners are never capped.

  • Engine default: none ([], in a DM and in a group). A member’s turn keeps AskUserQuestion and hitl_request_grant and nothing else until a rule names what they may reach — capabilities: [research:read], or ['*'] to lift the ceiling. Resolved like commands: engine → bots.<bot>.default → dm | chats.<id> → policy.chat(); policy.sender() may name one person’s own ({ tier: 'member', capabilities }). A Secretary counterpart is capped at the bot’s default ⊕ dm ceiling.
  • One intersection. The turn’s effective grant is (role grant ∪ live grants) ∩ ceiling — a '*' role becomes the ceiling; a grant an owner approved for the agent widens their turns, never past a member’s. Everything reads that one set: the tools offered and callable (§ 7.3), the Claude builtins, the capabilities module, an agent the turn creates (capability_ceiling), and the memory the role puts in the prompt — MEMORY.md, periods, the recent window and recall are a memory READ, carried on a member’s turn only when the ceiling holds memory:read (or '*'), the same cut the memory tools take.
  • It never drops on the way. It rides the turn’s auth (auth.access = { tier, capabilities }): a CLI turn’s MCP URL, a turn the agent hands to an existing agent (agent_call / agent_enqueue), a prompt it schedules (schedule_create_prompt) or a resource watch it opens, a HITL resume, and a Retry of the failed turn (an owner pressing it replays the member’s text under the member’s ceiling, not their own). Messages of one subject under different access are never merged into one turn. An owner’s access stays on the owner’s own turn: a prompt an owner schedules fires uncapped, as before, and is not treated as an owner waiting.
  • A grant cannot lift it. hitl_request_grant refuses a capability above the ceiling before any card is raised, naming access_widen. Raising the ceiling is an access.yaml change, and adding a capability is a widening one (access_widen, the owner’s yes).
  • Known gap. Codex and OpenCode do not follow the grant on an unconfined turn yet (§ 7.3): the engine’s tools are capped there, their native builtins are not. Serve non-owners from a Claude or API role.

Upgrading: every chat a non-owner talks in now runs their turns with no capability. For each served group and each dm that admits members, add the capabilities its members need (access_widen, or by hand) — the doctor’s Chat access check and access_overview read the effective member grant.


8. Database — one instance, two owners, two migration lines

You own the PostgreSQL instance. Inside it, ownership is split by schema and the split is absolute:

PG schemaOwnerDeclared inMigrated fromJournal
omnislashengine@omnislash-ai/infra (shipped)@omnislash-ai/infra/migrations (shipped)drizzle.__engine_migrations
publicbrain{brainRoot}/db/schema.ts (convention){brainRoot}/db/migrations/drizzle.__brain_migrations
drizzleengine——holds both journals

Rules:

  1. The engine line applies first, always. So a brain table may carry a foreign key into omnislash.*; the reverse is impossible by construction.
  2. db/schema.ts is a convention, not a config key. No env var, no omnislash.config.ts field: the engine probes {brainRoot}/db/schema.ts and, if it exists, adds the brain line. A brain with no such file simply has no brain line. (BRAIN_SCHEMA_PATH no longer exists — BRAIN_ROOT is the only knob, and both db/schema.ts and db/migrations/ derive from it.)
  3. db/schema.ts exports brain tables only. Never re-export an engine table from it: your migration line is generated from that module alone, and a leaked engine table (or enum) makes your migrations re-create engine objects. Importing an engine table for a foreign key is fine.
  4. Bare pgTable(), imported from @omnislash-ai/infra/drizzle. Not pgSchema('omnislash') — that is the engine’s namespace, and the brain never writes there (no migration, no db_execute, no psql). Not drizzle-orm/pg-core either: the engine re-exports every primitive so your tables land in the same drizzle instance as the client that queries them. 4a. Engine ids are opaque text. Every id the engine mints carries a prefix naming its row (ses_ an agent session, conv_ a conversation, run_ a workflow run, req_ a HITL request, …), and rows minted before the prefixes keep a bare cuid2 or uuid — both stay valid. A brain column that references an engine id is text (a uuid foreign key into omnislash.agents(id) fails the engine migration that retyped it, and so does a view reading one of the retyped columns until it is dropped), and nothing may parse an id’s shape: the prefix is a display hint, never a validity check (database.md § 1).
  5. Migrations are committed SQL, applied on deploy. omnislash db generate --name <n> → review the SQL → commit db/migrations/ (the .sql and meta/) → the db-migrate one-shot applies it on the next deploy. The deploy stops the world (operations.md § Brain CI/CD): every engine service stops — the queue worker first finishes the jobs it is running, in-flight engine invocations are killed — then the migrations run, then the new version starts. Nothing of the previous version runs while a migration does, so a migration may drop or rename what the previous version read, and a job payload, a pipeline input or an event never has to stay readable by the version one deploy back. What does cross a deploy is durable state — rows, queued jobs, your in-flight pipeline runs — and a release migrates it rather than tolerating its old shape: in SQL, in a step of the release, or by re-deriving it from rows at boot (a run’s error head — the cause its failure message reads as — is filled for the failures recorded before it by the worker’s first boot after the upgrade). A failing migration stops the deploy with no rollback: the services stay stopped until a fixed deploy. Every deploy is a few minutes of downtime. The engine’s own long waits do not live in journals. A loop cycle keeps its state on its row and is rung when something it waits on happens, so an engine upgrade never strands one: a cycle step caught by a deploy just runs again on the new code (self-evolving.md § A cycle, phase by phase). Your pipeline runs are not killed: they replay on the new code, and the engine code around them (the instrumentation, the SDK helpers) keeps its journal shape pinned by the engine’s own snapshot tests, so an engine upgrade does not change the steps your in-flight runs replay unless its upgrade notes say so. An agent turn the deploy cuts off continues on its session once the new version is up (at most sessions.continueAfterRestart times, then it ends interrupted), and while a turn runs brain-sync holds a push that would deploy, up to brainSync.deployHoldMinutes (agent-observability.md § What happens to a turn a deploy cuts off?). An engine upgrade that needs something from your brain says so in its upgrade entries, which omnislash upgrade hands to the agent doing the upgrade.
  6. An applied migration is immutable. Its checksum is recorded when it runs; editing it fails every subsequent migrate. Corrections ship as new migrations, generated on top of the current head — never renumbered, reordered, or deleted.
  7. db/ is in the forbidden G-set (§ 2): the migration one-shot reads it from the image and runs before brain-sync, which itself waits on that migration — so it can never be hot-synced. Schema changes are a commit + deploy, never a live edit.
  8. <namespace>_* in public is a reserved name shape. Any view whose name starts with a namespace some role declared in its state: block is rendered into that role’s context on every turn, automatically and with no registry — the catalog is the declaration (state.md). The prefix is also the agent’s ownership line, for tables as much as views: the prompt module tells it that everything in public named <namespace>_* is the namespace’s to create, alter and drop, and that it never ALTERs or DROPs anything else (row writes stay allowed where its instructions say so) — a rule it can check from the name, where “what did I create” rests on history a fresh session does not have. Two obligations follow, and they are the brain author’s: don’t name your own tables or views into another role’s namespace (nova_report is nova’s whether you meant it or not — a view your migration creates is indistinguishable from one the agent wrote, and a prefixed table is one the agent may alter or drop), and a namespace is a name you allocate, not one the engine derives, so keep the ones you hand out distinct from the prefixes your own schema already uses. Tables are never rendered, only views; the namespace alphabet is [a-z][a-z0-9_]{0,31} and anything else fails the role load. The engine enforces none of the ownership rule — it is prompt-level; the hard gate is db:write at hitl: true (§ 7).

Relations an agent creates for itself through db_execute are ordinary public objects: outside your migration line, never dropped or adopted by the engine, and untouched by a redeploy. Full brain-side workflow (renames, concurrent indexes, adoption of a pre-migration database): database.md.


9. Values you own — engine default → brain → entity

Every number the engine acts on is a default, not a decision made for you. Thresholds, deadlines, counts, rates, model choices, policy toggles, prompts — the engine picks a defensible starting value so a fresh brain boots with nothing configured, then gets out of the way. Changing any of them takes no engine code, no fork, and no patched image.

Three places a value can come from; most specific wins:

TierWhere you write itScope
engine defaultshipped — you write nothingevery brain, until you say otherwise
brain-globalomnislash.config.ts (defineProject), constitution/MANIFEST.yaml, constitution/*.jsonyour whole brain
per-entityfrontmatter of *.scenario.md / *.heartbeat.md / *.hook.md / *.agent.md, role filesone entity

Everything under constitution/ is G-set (§ 2) — edit it and the next tick uses the new value, no restart. omnislash.config.ts is image-baked, so changing it is a deploy.

A declaration is not one of these values: whether a role has memory at all (its memory: block) is the role’s, with no brain-global switch to turn it on everywhere (§ 7.4). The tiers below tune the roles that declared it.

What is tunable today:

ValueBrain-globalPer-entity
Which memory lines an agent uses — the one it curates (write), the ones it may deposit into (report) — and how much it carriesconstitution/MANIFEST.yaml § memorytop-level memory: in a role file, then agent.memory on a heartbeat / hook (the beat inherits its agent: role’s block and patches it one level; report belongs on the beat) — memory.md
Which languages memory search matches by word stem (report finds reports, встреча finds встрече) — default ['english'], one language per script; a change rebuilds the search index with no embedding costdefineProject({ memory: { languages } }) — memory.md § Languages your memory is written in— (one search index per brain)
Role-prompt budget — how large a role BODY may be before /system/context reports role-heavy and doctor Context budget warns (observation only; default 6000 chars, 0 = off) — role-prompts.mdconstitution/MANIFEST.yaml § promptBudget.roleChars (needs an engine that knows the section — an older one rejects the manifest)promptBudget: { roleChars } in the role file
When a working session reads Stuck — no activity (no stream event, no row write) for this long (observation only; default 600 s) — agent-observability.mdconstitution/MANIFEST.yaml § sessions.stuckAfterSeconds (needs an engine that knows the section — an older one rejects the manifest)sessions: { stuckAfterSeconds } in the role file
How many times a turn an engine restart (a deploy, a crash) cut off is continued on its session before it ends interrupted (default 3; 0 = never) — agent-observability.mdconstitution/MANIFEST.yaml § sessions.continueAfterRestartsessions: { continueAfterRestart } in the role file
How long brain-sync holds a push that would deploy while an agent’s turn runs, from the oldest held commit (default 60 min; 0 = never hold) — agent-observability.mddefineProject({ brainSync: { deployHoldMinutes } })—
Context window — the marks at which a chat is nudged to /compact and the agent compacts itself, and the context report’s thresholds (§ 7.2)constitution/MANIFEST.yaml § contexttop-level context: in a role file, key by key
Agent state — the Postgres namespace and how much of it reaches the promptconstitution/MANIFEST.yaml § state — numbers only, views / rows / chars; a brain-wide namespace is refused, since one prefix for every role is a collision by constructiontop-level state: in a role file, namespace included (a heartbeat inherits its agent: role’s block) — state.md
Feature contexts — how much a context attached to a turn may take (chars 4000), how long an inferred link keeps it (keep 3 turns), and the whole turn’s ceiling (turnChars 12000, maxActive 4), and how long one call of a *.context.ts may take (codeTimeoutMs 2000) — § 9.8, contexts.mdconstitution/MANIFEST.yaml § contexts (needs an engine that knows the section — an older one rejects the manifest; codeTimeoutMs needs one that knows the key)chars / keep in the *.context.md frontmatter; turnChars / maxActive / codeTimeoutMs are a turn’s or the brain’s, no one context’s
Widgets — lifetime (ttlHours 24) and its cap (maxTtlHours 168), launch-data age, where submits go (deliver: turn · chat · events), debounce and its ceiling (maxWaitMs), size caps, the opener’s-side context a widget turn carries (maxTurnContextBytes 8 KiB), submit rate, a page tool call’s deadline (toolTimeoutSeconds), default audience, whether code’s chat lines are posted (transcript: chat · off), how many one agent keeps open (maxOpenPerAgent 5) and how long closed ones are kept (retentionDays 30) — § 9.9, widgets.mdconstitution/MANIFEST.yaml § widgets (needs an engine that knows the section — an older one rejects the manifest; the caps and retention are this tier’s alone)policy: in the <name>.widget.yml, then widget_create’s ttlHours / deliver (never transcript)
Which engine tools exist for your agentsconstitution/engine-tools.json— (§ 7)
Capability risk / whether an action needs approvalconstitution/engine-capabilities.json— (§ 7)
Context-window notify + auto-compact marksconstitution/Agents/system/role-general.md § context:any role file (§ 7.2)
System prompts (planner stages, evaluation, loop, grant parsing, and every Agents/system/**/role-*.md)your own file at the same path under constitution/—
Grading + self-evolving (sampling, trigger threshold, the k-of-n trigger window, the post-merge watch block — mode, a window and count that default to the trigger’s, the evidence deadline — the review deadline, analysis cooldown + ceiling, per-gate autonomy, the pre-merge check block, the per-round spend ceiling, judge-health ceiling)constitution/MANIFEST.yaml § evaluation: / loop:same keys in entity frontmatter — § 9.1 below for the seam-by-seam table, self-evolving.md for what each one does
The loop’s two RULES — the pre-merge check’s candidate/incumbent comparator and the trigger (which the post-merge watch settles by too, asked at every grade of the observed window)defineProject({ loop: { window, shouldOpen, verdict, compare } }) — a function has no YAML form, so neither lives in the manifestverdict / shouldOpen in the entity’s <entityId>.loop.ts — self-evolving.md
Who may use a bot where, which role serves each chat, when the bot answers, what members may run and how often — the chat frameengine default: only owners (TELEGRAM_ALLOWED_IDS + identities: owner); a DM answers always, a group is served only once its rule names a role and then answers when addressed; members run help (plus the connect flow in a DM); 30 turns / hour / member; the context block shows members’ and owners’ lines; an unknown group asks you. Brain tier: constitution/access.yaml — identities (a tier per tg:user:<id>) and bots.<bot>.default (may not name a role), bots.<bot>.unknownChatsbots.<bot>.dm and bots.<bot>.chats."tg:chat:<id>" (a group rule must name its role), then the code tier bots[].policy — defineChatPolicy({ chat, sender, engage }), each undefined deferring; context / limit replace whole. The old bots[].engagement / bots[].engaged / top-level engagement: fail the load — chat-access.md, engagement.md
Per-task safety-net timeoutworkflows.execution.defaultTaskTimeoutMsscenario execution.taskTimeoutMs
Your brain’s timezone — every schedule: is read in it, and so are “today” and “this week” — default UTCdefineProject({ locale: { timezone } }) — the only one: constitution/MANIFEST.yaml has no timezone key and refuses one—
Whether anything fires on a clock — scenario crons and heartbeat ticks alike; hooks and manual runs are not on a clock — default offconstitution/MANIFEST.yaml § scheduler.enabled, live (a flip needs no restart)the entity’s own enabled
How long a business KPI’s read may take before it is aborted and shown as timed out — default 8 s— (one slow source should not loosen every other read)timeoutMs on the KPI in defineProject({ kpis }) — kpis.md
How long a gate may take (<hook>.hook.ts, <name>.heartbeat.ts, § 3) — default 30 s, the same for both kindsconstitution/MANIFEST.yaml § hooks.gate.timeoutMs / heartbeats.gate.timeoutMsgate: { timeoutMs } in the *.hook.md / *.heartbeat.md frontmatter
How long a sub-agent’s QUESTION waits for its parent before a person is asked (parentFirstMs) — default 120 s, 0 = ask a person at onceconstitution/MANIFEST.yaml § delegation.parentFirstMstop-level delegation: { parentFirstMs } in the ASKING (child) role’s file — § 9.4
How long an API agent’s prompt prefix stays cached at the provider (promptCache.ttl: 5m / 1h) — default 1h; API agents only — § 9.5constitution/MANIFEST.yaml § promptCache.ttl (needs an engine that knows the section — an older one rejects the manifest)top-level promptCache: { ttl } in the role file
Which of a role’s tools ride turn 1 (toolLoading: deferAbove — API agents defer their tools behind tool_search past this many, default 30; eager — tools always loaded, also on direct Claude CLI) — § 9.6constitution/MANIFEST.yaml § toolLoading (needs an engine that knows the section — an older one rejects the manifest)top-level toolLoading: { deferAbove, eager } in the role file
What the engine posts into a chat on its own — the lowest notice level posted (level: all · news · failures · none, default news) and how long a repeat stays a silent edit before it is posted again (remindAfterHours, default 24, 0 = never) — notifications.md § Noticesconstitution/MANIFEST.yaml § notifynotify: in the *.scenario.md, *.heartbeat.md or *.hook.md frontmatter
When an entity reads Late, Down, Failing or stuck (health_entity / health_list) — grace clamp(10% of the period, 5 min, 1 h) (graceRatio / graceMinMs / graceMaxMs), Down after 2 expected fires that never started (downAfter), Failing after 3 failed runs in a row (failingAfter), stuck after 2 h queued / running / retrying (stuckAfterMs)constitution/MANIFEST.yaml § health (needs an engine that knows the section — an older one rejects the manifest)health: in the *.scenario.md / *.heartbeat.md / *.hook.md frontmatter, per key
Agents a scope: fire hook may launch per chat per minute (§ 3) — default 6constitution/MANIFEST.yaml § hooks.chatLaunches.perMinutechatLaunches: { perMinute } in the *.hook.md frontmatter
How long the spend ledger (omnislash.usage_records, one row per priced turn — features.md § Usage & Cost Accounting) keeps a row — default 365 days, 0 = foreverconstitution/MANIFEST.yaml § usage.retentionDays (needs an engine that knows the section — an older one rejects the manifest)— (one table, one cut: per-entity windows would make cross-entity spend incomparable)
Money budgets — USD ceilings on METERED spend (run / day / month) for the brain, each scenario and each role; a spent budget refuses the next turn and a person is asked to raise it — § 9.7, budgets.md. Default: no limit anywhereconstitution/MANIFEST.yaml § budget — brain limits, plus scenario / role defaults each scenario / role gets as its own ceiling (needs an engine that knows the section — an older one rejects the manifest)top-level budget: in a *.scenario.md (run / day / month) or a role file (day / month), folded per window; null = no limit here, overriding the default
How long a handled inbound Telegram event keeps its payload (telegram-account.md) — default 7 daysconstitution/MANIFEST.yaml § telegram.inbound.payloadRetentionDays— (a row is not one hook’s)
When a Telegram account counts as limited and how the engine watches the limit (telegram-account.md § When Telegram limits the account) — a send FLOOD_WAIT of 60 min or more, @SpamBot asked every 24 h, the status chat told after 2 daysconstitution/MANIFEST.yaml § telegram.accountLimit.floodWaitMinutes / checkEveryHours / alertAfterDays— (an account is not a brain entity)
How long a handled GitHub event keeps its payload (integrations.md § GitHub events) — default 30 daysconstitution/MANIFEST.yaml § github.payloadRetentionDays— (a row is not one hook’s)
How long the action log keeps a row (omnislash.audit_records — who changed or decided what; read it with audit_list) — default 365 days, 0 = foreverconstitution/MANIFEST.yaml § audit.retentionDays (needs an engine that knows the section — an older one rejects the manifest)— (one log, one cut: a row is not one entity’s)
How long a sent message keeps its origin (omnislash.message_origins — which feature, context and entity a bot message belongs to, what a reply links back through, § 9.8) — default 365 days, 0 = forever; a pending HITL card’s rows and a widget’s chat-lines messages’ rows are kept at any ageconstitution/MANIFEST.yaml § messageOrigins.retentionDays (needs an engine that knows the section — an older one rejects the manifest)— (one table, one cut)
How a resource watch behaves — debounce 45 s capped at 5 min (debounceMs / maxWaitMs), lifetime 72 h (ttlHours) capped at 720 h (maxTtlHours), 25 deliveries (maxDeliveries), only trusted authors (authors: trusted — repository owners, members, collaborators and bots; all admits everyone, fenced as always), your own App’s echoes dropped (dropEcho), checks on a superseded commit dropped (dropStaleRevision), 20 000 characters per batch (maxBatchChars)constitution/MANIFEST.yaml § events.watch — read at every use, so an edit reaches watches already running (a lifetime is fixed when the watch is created); maxTtlHours exists only herethe watch’s own override, set by whoever creates it — every key but maxTtlHours
When the daily brief is written into the chief’s DM with the primary owner (features.md § Daily brief) — default 07:45 in locale.timezone; disabled = never (“Send now” / brief_send still work)constitution/MANIFEST.yaml § brief.schedule— (the brief is the operation’s)
What the chief is asked to write in the brief — default the engine’s note; the chief’s persona sets the voiceconstitution/MANIFEST.yaml § brief.instructions—
How many correction turns a pipeline’s runAgent step gives a structured output its outputSchema rejects — default 1, 0 = none (pipelines.md § runAgent); each sends the agent the issues and its rejected outputconstitution/MANIFEST.yaml § pipelines.outputCorrections, read when the step startsoutputCorrections on the call
What a code change (a deploy, a hot reload of *.pipeline.ts) does to a live pipeline run it breaks — the new code cannot replay the run’s journal: end (default — killed, as run_kill) or restart (reset, as run_reset: the steps before the wait run again, so only when they are idempotent or gated on your rows) (pipelines.md § Editing a .pipeline.ts with in-flight invocations)constitution/MANIFEST.yaml § pipelines.onCodeChange (needs an engine that knows the key — an older one rejects the manifest), read at each settleexecution.onCodeChange in the *.scenario.md frontmatter
How long a pipeline’s awaitApproval card waits before it expires — default 168 h, 0 = no default deadline (pipelines.md § HITL helpers); a waiting run keeps its journal across every deploy, so an unanswered card must end. requestApproval keeps no defaultconstitution/MANIFEST.yaml § pipelines.approvalExpiresInHours, read at each approvalexpiresInMs on the call (null = never)
Which role the dashboard’s Fix with agent (System › Doctor) hands a failing check to — default the chief’s role (chief.role); point it at an engineering role when your chief is a persona. One agent per check, its role fixed when it is first createdconstitution/MANIFEST.yaml § dashboard.doctor.fixRole—
Which voice an agent speaks in — voice_send, voice_render, voice replies (the chain’s order, each step’s voice and OpenAI instructions) — default ElevenLabs → OpenAI, stock voices (voice.md)defineProject({ speech: { chain } }) (image-baked)speech: { chain } in the role file (hot) — replaces the brain’s chain whole for that role’s agents, never merged per step
How a voice message is heard — the transcription chain (each step’s model, a whisper step’s prompt) and language — for messages in the chats a role answers and its audio_transcribe; default every provider with a key, auto-detect (voice.md)defineProject({ transcription }) (image-baked)transcription: { chain, language } in the role file (hot) — replaces the brain’s block whole for the chats that role answers (a Secretary counterpart’s route included), never merged per step or key
Chat reaction emoji (seen / ok / failed)defineProject({ reactions }) — defaults 👀 / cleared / 👾; null on a verdict clears instead of stamping—
Soft-deleted agent retentiondefineProject({ agentRetention }) — default 24h. Past the TTL the row is deleted for real, and its conversation history goes with it; an agent holding a resource watch is kept until the watch ends — agent-observability.md—
Named notification destinationsdefineProject({ notifications.channels })— notifications.md
Which tools an agent of a role holds— (each role file is its own grant; role-general.md is the general role’s, not a default)the role file’s capabilities: (§ 7.3) — nothing declared grants none
What the boss agent does, and how it decidesconstitution/Agents/system/role-boss.md (replaces the engine’s copy whole)— boss-mode.md
How much reach a boss run opens withconstitution/MANIFEST.yaml § boss:— boss-mode.md
Extra permissions on the deploy’s GitHub AppdefineProject({ git: { appPermissions } })— widens or raises the engine floor, cannot narrow it
Whether that App may be installed on accounts other than its owner accountdefineProject({ git: { appPublic } }) — default true—

Checking which tier actually won is otherwise guesswork across three files. For the grading + self-evolving row it is rendered: the entity’s policy view at /evolution?selected=<targetKind>:<targetId>&view=policy shows every knob resolved beside the tier that supplied it — self-evolving.md § Watching the loop.

One value merges the other way: the engine layer is a floor you widen, never a ceiling you narrow.

  • git.appPermissions is merged with the permissions the engine cannot function without (contents: write, pull_requests: write, metadata: read, workflows: write, statuses: write — the loop publishes omnislash/diff-gate on the apply’s pushed head before the PR opens and its pre-merge check as omnislash/validation, the statuses a base-branch rule should require; omnislash doctor checks that under GitHub merge gate — plus checks: read and issues: read, which the App’s event subscription costs). A GH_TOKEN PAT on the fallback lane needs the same set (minus checks, which only the App’s webhooks cost). Per key the stronger level wins: add resources freely and raise a floor level ({ issues: 'write' } gives write), but { contents: 'read' } leaves the App on write with no error. Widening after the App is registered forces every installation to re-approve, so author it before you press Connect; the doctor check GitHub App subscription names what an existing App lacks.

Engine recovery timing is not one of them. How often the engine repairs its own intake — re-routing an unrouted inbound event, sweeping expired watches, reconciling the GitHub App’s delivery log (every 5 minutes), probing a watched PR (every 5 minutes, 30 s after CI activity, 10 minutes of grace before a head with no checks counts as settled) — is engine mechanics against GitHub’s delivery window and rate limits, not a domain value; it is fixed. What those mechanics deliver to your agents (how a watch batches, whom it admits) is a value you own: events.watch above.

Not every value carries all three tiers — several are brain-global only because nobody has needed them to differ per entity yet. If you hit a value that is fixed in the engine and your domain needs it different, treat that as an engine gap, not something to work around. Report it: the fix is a tier, and it ships for every brain.

9.1 The loop’s seams, one row each

The grading + self-evolving row above is one line in the table and a few dozen knobs in practice, so here it is opened out. Every one of them resolves the same way — engine default, then constitution/MANIFEST.yaml, then the entity’s own frontmatter, nearest wins — and what differs between rows is what “nearest wins” does to a BLOCK, which is the thing worth reading before you write one.

KeyWhere you may write itIf you write nothingHow your tier merges
evaluation.enabled / loop.enabledmanifest · entitytrue inside a block you wrote — but writing no block is offthe two tiers AND together: false in the manifest is a brain-wide kill switch no entity can override, and the manifest never switches an entity on. loop.enabled: false stops new cycles and closes every cycle that has not merged. A proposal on its card has the card cancelled. An open PR has its auto-merge disarmed and is closed. The cycle settles skipped. The switch is re-read at each un-merged step and on the merge wait’s poll, so it takes effect within one poll interval (at most 6h). A merged change’s watch and revert run on. evaluation.enabled: false also stops the grading those wait on
loop.thresholdmanifest · entityevery graded run is analysed — the absence is the setting, not a gapyour value replaces
loop.presetmanifest · entityrepair — a bad graded run opens an analysis over the entity’s last n grades, and the post-merge watch counts bad runs. evolve improves what works: every trigger.windowRuns grades an analysis opens with nothing wrong, and the watch compares the entity’s metrics before and after the change, the objective inside a watch.confidence interval; a written threshold or trigger.minFailures under it fails to loadnearest tier wins. A preset names which RULES run, never the numbers they read: every number stays in the block around it
loop.trigger { windowRuns, minFailures }manifest · entity2 bad runs out of the last 5 gradedwhole block. Write trigger: anywhere and you own both keys; the one you leave out comes from the engine, never from the tier above you
loop.check { mode, inputs, trials, deadlineHours, incumbent }manifest · entity5 frozen inputs, 1 trial each, a 24h wait, incumbent: frozen — an earlier comparable grade stands for the incumbent on a history input, where replay re-runs the incumbent on every input (the input that opened the cycle is re-run either way). mode has no default number — the engine works it out per cycle from the entity’s plan: replay for a workflow scenario whose plan needs no capability a human has to grant, reaches nothing above moderate, opens no PR and runs in your brain repo; none otherwise, and always for a heartbeat or a pipelinewhole block
loop.watch { mode, windowRuns, minFailures, deadlineHours, confidence, onInconclusive }manifest · entityobserve, a 168h wait, the window pair copied from your resolved trigger — widen the trigger and the watch widens with it — a 0.9 central interval, read by the evolve comparator only, and onInconclusive: keep: a shipped change the watch could not prove stays shipped; revert undoes it through the same revert PR a regression gets (the row still settles unvalidated); not read under mode: nonewhole block
loop.autonomy { decide, merge, mergeMethod, maxConsecutiveRegressions, onInconclusive, onUnmeasured, requireCalibration }manifest · entityhuman at both gates, squash, breaker at 2, onInconclusive: human, onUnmeasured: pause, requireCalibration: false. requireCalibration: true holds both auto gates for a person while any judged human: true metric is not calibrated against people (no effect without one — omnislash doctor says so). null on the breaker switches off the STREAK ceiling only — a change that shipped on its own and could not be measured still pauses the entity, unless onUnmeasured: continue. onInconclusive: human | ship | close answers a pre-merge check that could not judge the candidate. Under decide: auto the loop’s proposer and implementer run unattended: a grant request fails instead of waiting, and a broken analysis or apply closes the cycle failed with no cardwhole block
loop.budget { maxUsdPerRun }manifest · entityunpriced: nothing caps what one run of this entity’s loop may spend on its own agents. Declared, it bounds the RUN — each agent is handed what the ones before it left, and a run with nothing left opens nonewhole block
loop.evidence { runs, maxTokens, includeTrace }manifest · entitythe last 5 graded runs with their traces, under 40 000 tokenswhole block
loop.lessons { maxRecalled, retentionDays }manifest · entity10 lessons recalled, kept 180 dayswhole block. retentionDays is applied by a sweep over every entity at once, so what actually deletes rows is the MANIFEST value; recall reads maxRecalled alone, so an entity-tier retentionDays is read by nothing (the dashboard refuses to write it there)
loop.reviewDeadlineHoursmanifest · entitya week at each wait of an analysis round — a grant request the proposer or the implementer stops on, the proposal card, the retry card a failure offers (none of them exists under decide: auto). The merge wait is watch.deadlineHours’your value replaces
loop.cooldownHoursmanifest · entity6h after an ordinary settle, 24h after a regressed one — the one loop default that depends on what just happened. Declare it and your single number covers both casesyour value replaces
loop.maxAnalysesPerDaymanifest · entity3 proposer agents per rolling 24h; null removes the ceiling and is a real answer, not an omissionyour value replaces
loop.windowStalledAfterHoursmanifest · entitytwo weeks (336 h) a not-ready window’s count may stand still before the entity is listed under needs you as stalled; 0 never. A window that names a person (awaitingPerson) is listed for that insteadyour value replaces
loop.proposer.pipelinemanifest · entitynull — the engine’s proposer agent writes the change. A pipeline id makes that pipeline the author: it gets a typed brief and answers whole files (or null to set the cycle aside), and its files meet the same surface, diff gate, PR, check, watch and revertyour value replaces; absent = the tier above; an entity’s null takes the proposer agent back under a brain-wide proposer pipeline. loop.proposer folds per field — the one block inside loop: that does: each of its keys merges on its own, so an entity that writes one keeps the brain’s others
loop.proposer.modelRefmanifest · entitythe proposer role’s own ref — constitution/Agents/system/loop/role-proposer.md is where you re-aim every analysis at once. No effect while a pipeline writes the change (doctor Loop proposer models warns; a ref models.json does not resolve fails it)your value replaces
loop.proposer.instructionsmanifest · entity[] — the engine’s brief alone. Lines here are ADDED after whichever brief the cycle reads (the kind’s, the preset’s, an idea’s; the engine template or your copy under constitution/Loop/proposer/) as ## Instructions for every entity of this brain / ## Instructions for this entity, and reach a proposer pipeline as ProposerBrief.instructionsboth tiers kept, the manifest’s first — an entity adds to the brain’s lines, never drops them
loop.editablePathsentity only — a manifest that names it fails to loadnothing extra: the accepted proposal’s own files are the whole allow-listentity-only, so there is nothing to merge
loop.protectedPathsmanifest only — an entity that names it fails to loadthe engine floor alone — which already closes memory/, every memory.sources entry you declared, every heartbeat’s gate (*.heartbeat.ts), the deploy perimeter (db/, .github/, docker/, the Dockerfiles, .dockerignore) and the CLI agents’ config (.claude/, .codex/, .mcp.json, opencode.json). Beside it the engine always closes every OTHER loop entity’s definition files (*.scenario.md, *.heartbeat.md, a pipeline’s <id>.pipeline.ts) to a change proposed for one entity, and — for a lock-group member — its existing shared files (*.agent.md, skills/, constitution/Planner/) unless its own editablePaths lists themadds to the floor. You widen the deny list brain-wide; nothing you write removes an engine entry, and no entity can exempt itself from yours. The floor closes each *.loop.ts but not the helpers it imports, nor the code that writes lineage — list those here
loop.maxChangedLinesmanifest · entity600 lines for the whole change — added plus removed across every file the gate commits, a created or deleted file counting its whole length. A binary file the loop wrote is refused, not counted. An optimizer that rewrites the same artifact every cycle grows it without a boundyour value replaces (a plain scalar, nearest tier wins). Past it the apply FAILS naming the count, the bound and the three largest files: nothing is committed and no PR opens
loop.groups { <id>: { lock, repair } }manifest only — a group is a statement about several entities, so no entity may declare or redefine oneno groups: every entity waits only on its own cycles. A declared group defaults to lock: true (one live change across its members; a regressed member’s awaited revert holds it too) and repair: bypass (a repair member’s cycle goes through a busy group, and the holder’s watch then closes confounded); wait holds repair cycles too; lock: false only confoundsper group id, your declaration whole
loop.groupmanifest · entity[] — in no groupthe list whole, nearest tier wins; [] leaves every group the brain put the entity in. A name MANIFEST does not declare joins nothing and omnislash doctor fails it
evaluation.judge.maxUngradedSharemanifest only — no per-entity tier, on purposea third of a challenger batch may go ungraded before the cycle refuses to read the rest as a verdictwhole block at the tier that writes judge:
evaluation.methodmanifest · entitydefault — the method file the judge grades with. none grades with no judge: no model is called, passed is read off the declared floors, no quality guard is added, no maxPerDay ceiling is owed, and a judged metric, a verdict-role quality or requirements fail the loadyour value replaces
evaluation.metrics.<id>.aggregatemanifest · entitymedian — how the metric’s per-run values become one number in the window’s level, the evolve bootstrap and the pre-merge check’s movement over its inputs; declare mean on a 0/1 shareper metric id, per key — the entity’s key over the manifest’s
evaluation.metrics.<id>.optionalmanifest · entityfalse — a declared computed metric measure() / window() leaves out fails the grade; true makes the omission no observation. Refused on a judged metricper metric id, per key
evaluation.metrics.<id>.scale { min, max, step?, labels? }manifest · entity — the tier that introduces the metric (it is part of what the id means, like kind)a metric a person rates (kind: reported, or judged with human: true) answers on 1–5 with a rubric; a judge-only metric on the judge’s 0..1. The vector carries the answer normalized to 0..1, so target / floor / tolerance are written on 0..1identity: a lower tier may not rescale it
evaluation.human { ask, spotCheck, expiresHours, agreement: { minPairs, tolerance, threshold, maxPairs } }manifest · entityask: never (no rating cards; always — a card per graded run; until-calibrated — cards until the judge is calibrated, then spotCheck 0.1 of runs), expiresHours: 48 (a carded run waits that long for a person). agreement: minPairs: 5 — the watch reads a human-rated judged metric off people only when BOTH windows hold that many rated runs (the judge in both otherwise), and calibration is unknown below it; tolerance: 0.25 (0..1) — how far judge and person may differ on a run and still agree; threshold: 0.8 — the share of agreeing pairs that makes the judge ok; maxPairs: 20 — agreement is read over the newest pairswhole block
evaluation.context { priorEpisodes, maxTokens }manifest · entity (a chat role’s own evaluation:)0 earlier episodes with the same person as labelled context; a 40000-token transcript, cut in the middle with a visible mark past it. Read only by a chat episode’s judgeper key, nearest tier wins
chat.episode.idleHours · chat.onRoleChangemanifest chat: · a chat role’s own chat:24 hours of silence after the role’s reply end an episode; keep — a role change goes on in the same session and episode (reset closes the episode role-change and starts the session over). The loop never edits the chat: blockper key, nearest tier wins
evaluation.gradeAfterHoursmanifest · entity0 — the judge looks the moment the execution ends. The engine cannot know that your outcome lands later than your run, so a lag is yours to declare: views at 72h, a delivery rate that settles overnightyour value replaces (a plain scalar, no block). An entity 0 beats a brain-wide lag
defineLoop({ window, shouldOpen, verdict, compare })defineProject({ loop: { … } }) · <entityId>.loop.ts — a function has no YAML form, so neither belongs in the manifestthe preset loop.preset names — for repair, the engine’s own comparator and its k-of-n trigger countnearest wins, whole function: the entity’s module beats your project config. loopFunctionsFor answers { fn, source } per function, and source is what the post-merge watch branches on
defineLoop({ cases })<entityId>.loop.ts onlyno golden casesentity-only, and added to the inputs frozen from history rather than replacing them

“Whole block” is the rule to internalise. For every multi-key block above, the nearest tier that writes it owns all of its keys, and the ones you leave out fill from the engine, never from the tier above you. So a manifest trigger: { windowRuns: 8, minFailures: 3 } plus an entity trigger: { minFailures: 4 } gives that entity 4 of 5, not 4 of 8 — the entity wrote the block, so the engine’s windowRuns filled the gap. Write the keys you mean, together. Half a policy merged with somebody else’s other half is a policy nobody wrote, which is why the engine refuses to build one.

Three defaults are computed rather than fixed, and none of them is a number you can find in this table: check.mode is worked out per cycle from the entity’s plan, watch.windowRuns / watch.minFailures are copied from your resolved trigger, and cooldownHours depends on whether the last settle was a regressed one. Declare any of them and the computation stops for that entity — which is the point, but it also means you have taken over a decision that was tracking something.

Writing these from an agent. loop_policy lists every key above by the id loop_set takes (trigger.windowRuns, evaluation.sample.maxPerDay, evaluation.metrics.<id>.<field>, groups.<id>), with its value and the tier it came from. The tier a write lands in is the target’s presence: an entity named → its own frontmatter; neither half → constitution/MANIFEST.yaml. op: remove drops that tier’s own value so the tier below answers. A write of one key of a whole block at the entity tier writes the block as the entity sees it now, with that key replaced — so the entity owns the whole block from then on, per the rule above.

Full treatment of every key here, with the loop’s own vocabulary, grouped into three levels (basic · tuning · power) and mapped on one screen: self-evolving.md § Seam map.

9.2 What the loop launched runs dry — your tools and pipelines too

When the loop replays one of your workflows before a merge, that run is a MEASUREMENT: it must not message a customer, write a row that the business reads, publish a short or start other work. The engine marks every execution the loop launched — the replay, every sub-agent it spawns, every pipeline it (or a loop proposer) leads to — as loop-launched, and holds it to one rule: read live, act on nothing. You cannot switch this off; it is what makes a replay safe to run at all.

What the engine does for you:

  • Engine tools that write, launch or send run dry. notification_send, db_execute, scenario_start, email_send, telegram_send, artifact_publish, kv_set, schedule_create_prompt and every other engine tool that acts outside the run answer with a success marked dry-run — the call and its arguments stay in the transcript for the judge to read, the effect never happens. Authorization still applies: a call the run could not make in production fails the same way here.
  • Reads run live, and so do the run’s own working tree (bash, file writes) and sub-agents (which inherit the mark).
  • Nothing reaches a person. A question, grant request, approval or input card from a loop-launched execution is refused before it is written — nobody’s answer can be faked.
  • A pipeline launches no blueprint run. runWorkflow / safeRunWorkflow in a loop-launched invocation fail with TerminalError before anything is submitted: the run would be an ordinary run (the mark is not inherited by a blueprint run) that acts on the world, and it would be graded as one of the scenario’s own runs. Branch on ctx.loopLaunched before the call when a replay should go on without it.
  • Delivery and write helpers act on nothing: context.notify(...) in a tool body returns { dryRun: true }; the pipeline SDK’s notify(ctx, …) sends nothing, emitEvent emits nothing and kvSet / kvDelete write nothing (each step is still journaled). callTool and runAgent from a pipeline carry the mark, so their calls run dry the same way. recordSpend is the one helper that still writes: it books money that already left (§ 9.3), and the external call it prices is yours to skip.

What you owe:

  • Your tools. A tool that only reads needs nothing. A tool that writes, sends or launches either declares replay: 'dry-run' in defineTool / createTool — the engine then answers for it — or reads context.auth.loopLaunched and returns its success without the effect. A tool that declares nothing runs as written.
  • Your pipelines. Read ctx.loopLaunched (typed on restate.Context by @omnislash-ai/pipeline-sdk) and skip whatever your own code writes, sends or launches outside the engine helpers — your own tables, an external API, a raw fetch. A pipeline is never replayed on its own; the flag is true only when a loop-launched execution started it. Such an invocation is not one of the pipeline’s own runs, so it is never graded: it stays out of the pipeline’s windows, its calibration and its ratings.
run: async (ctx: restate.Context, input: { videoId: string }) => {
  const rendered = await ctx.run('render', () => render(input.videoId)); // live: measured
  if (ctx.loopLaunched) return { rendered, published: false };           // a replay acts on nothing
  await ctx.run('publish', () => publish(rendered));
  return { rendered, published: true };
},

9.3 Which run did this — the run identity you record as lineage

Every execution the engine grades has one public identity, { runKind, runId } — the same pair your measure(run) receives. The engine hands it to your code while the work happens, so you can write it next to what the work produced:

  • A pipeline reads ctx.runRef: { runKind: 'pipeline', runId, definitionHash } — its own invocation, and the sha256 of its scenario’s *.scenario.md + primary <id>.pipeline.ts as it started (null if the scenario file is gone). A child pipeline is its own run.
  • Your tools read context.auth.runRef: the workflow run for a task’s agent, the pipeline invocation for callTool and for an agent runAgent ran, the tick for a heartbeat, the chat EPISODE ({ runKind: 'chat', runId }) for any turn of a person’s direct chat with one of your roles. A sub-agent inherits its spawner’s; a CLI agent’s MCP session carries it. Absent where no graded execution owns the work (a chat with a system role, a group chat, an ad-hoc agent) — write lineage only when it is there.
  • Spend outside a turn — a pipeline that pays an external API itself (a scraper, an SMS gateway) books it with recordSpend(ctx, { service, costUsd, billing?, units? }): one row of the spend ledger attributed to this run (its execution, scenario, requester), counted beside agent turns by every reader of the ledger (omnislash.usage_records) — the run’s cost included — and recorded once across replays and retries. Details: pipelines.md.
  • A chat episode ends after chat.episode.idleHours of silence after the role’s reply (never while the role is still busy on a turn), when the role’s agent calls agent_close_episode (with no arguments: the turn’s own episode; an agent names no other agent’s — a person closes any agent’s open episode by id), or when your code calls closeChatEpisode(runId) from @omnislash-ai/infra/chat-episodes — the same call the tool makes. The next message opens a new episode. Details: self-evolving.md § Chat roles.
  • A chat role is graded and improved as a role entity (its runs the chat run kind) once its *.agent.md declares evaluation: / loop: (a system role never is — move it into a feature). Its measure(run) receives the episode (run.runId, run.completedAt = its close); the judge reads the episode’s whole dialog, earlier episodes with the same person only as labelled context (evaluation.context.priorEpisodes), cut in the middle with a visible mark past evaluation.context.maxTokens, under constitution/Loop/chat-privacy.md; the proposer gets each episode’s runId, shape and verdict and reads the dialog itself from the database (db_query), writing under the same privacy rule — the engine appends that file to every chat proposer brief under ## Privacy. A change is judged on episodes begun after it went live under one role version — a version is what the role does (body, frontmatter without evaluation: / loop: / chat:, named skills), and the loop never edits the chat: block. It owes sample.maxPerDay like a scheduled entity. The transcript survives compaction of the chat’s history. Details: self-evolving.md § Putting a chat role in the loop.
  • The dashboard shows a chat role’s episodes on its Evolution view (/evolution) instead of the Rate runs card: each one’s start, close and why, the judge’s grade and numbers, people’s ratings, a Rate on an ended one (grading one the sampler passed by — any kind’s Rate runs card lists unsampled runs too) and its transcript — the role’s agent timeline cut to the episode’s turns, at the address the rating card links to (/go/role:<role>?episode=<runId>). Details: self-evolving.md § The owner’s rating.

It says what the work counts toward, never who acts: it grants nothing and owns nothing. Where to record it and how measure() joins on it: self-evolving.md § Recording lineage.

Who did it — attribution. Separate from lineage: the createdBy a write records (a run, a pipeline invocation, an artifact, a HITL answer) names the immediate caller, resolved from the calling context in this order: the workflow run that owns the turn → the pipeline invocation that owns it → the calling agent → system. So a pipeline’s callTool, and every tool an agent calls inside a runAgent step, record { kind: 'pipeline', runId }; the agent row itself still records who spawned it.

Idempotent edges — starting the same work twice. scenario_start takes an optional idempotencyKey, on either engine. Give it a stable name for the business effect (<itemId>:<stage>) whenever a retry can repeat the call — pipeline code calling them through callTool (at-least-once), a webhook, an agent that may re-run a step. Without a key every call starts new work.

Tool / targetThe key is scoped toRemembered forA repeat returns
scenario_start on a pipeline scenario → a service or object handlerservice, object key, handler, idempotencyKeyRestate’s idempotencyRetention (1 day by default; set it in restate.service/object({ options }))the same invocation, existing: true
scenario_start on a pipeline scenario → a workflow runthe workflow key (idempotencyKey is ignored: the key already is the id)Restate’s workflowRetention (1 day by default); after that the key runs againthe same invocation, existing: true
scenario_start on a workflow scenarioscenario + idempotencyKeythe life of the run rowthe same run, existing: true

A keyed scenario_start call without input never parks on an input card — a workflow starts with {} (an unkeyed one on a scenario that declares input fields — a workflow’s blueprint, a pipeline’s inputSchema for its run handler — opens the input request and answers inputRequested). Long-term truth for a pipeline’s work is your own ledger row (pipelines.md § An operation’s items — pattern A); the key covers the retry window. Do not name a Virtual Object handler run: a keyed scenario_start call to it is taken for a workflow run and loses its idempotency key.

9.4 Sub-agent questions — the parent first, then the person

An agent another agent spawned (agent_task, agent_call, agent_enqueue, agent_create) runs without a chat of its own — on purpose: a sub-agent’s output must not stream into the parent’s chat. Only a business chat’s narrowing is handed down, so a business chat’s sub-agents stay restricted the way its main agent is. What the engine does with a sub-agent’s human-blocking request:

  • A question waits for the parent first. For delegation.parentFirstMs (the row above) the question is the parent’s alone: the parent’s agent_task / agent_call / agent_await returns awaiting_input with pendingHitl: { hitlId, kind, questions, deliverAt } (agent_get under current), and the parent answers it (agent_answer), hands it to a person now with a note (agent_escalate), or lets deliverAt pass. No card is pushed during the head start. A sub-agent whose parent was deleted asks a person at once.
  • Grant and OAuth requests go to a person at once. A parent cannot approve them; agent_answer refuses them.
  • The person is found by lineage. The card goes to the chat of the nearest agent above the asker that has one (the chat its current turn serves, else the chat it is scoped to), and only falls back to your HITL chat when none does. A business chat’s card still goes to the owner’s DM, never to the counterpart.
  • The card carries context: who asks (name · role), delegated by <parent> with an excerpt of the task, and the parent’s escalation note.
  • The parent’s two tools need no approval. agent_answer / agent_escalate ride agent:execute and act only on the caller’s own descendants — answering your own child’s question is no more than agent_enqueue sending it a message.

The value belongs to the asking role, so set it where the child’s work is defined: 0 on a role that works on the person’s live screen (they are the one to ask), a longer head start brain-wide when parents usually know the answer. A parent that wants no questions at all spawns the child with interactive: false. Walkthrough: human-input.md § Questions from sub-agents.

9.5 API prompt cache — how long the prefix stays cached

An API agent (a modelRef naming an API provider, not a CLI harness) sends its request in cache order: your role’s system prompt first — identical for every agent of the role — then the agent’s own environment and MEMORY.md, the conversation’s history, and this turn’s message (the daily window, the <agent-state> block, the clock, your prompt). The engine marks it for the provider’s cache where the provider supports it: the system prompt (shared by every agent of the role), the end of the history (read back by the conversation’s next turn) and the tail (read back by the next tool step of the same turn). You write nothing for that.

The one value you own is how long a write stays readable, promptCache.ttl:

  • 5m when the requests that share a prefix come less than five minutes apart — a fast heartbeat, a role whose conversations are tight tool loops.
  • 1h (the default) for human-paced chat and periodic heartbeats: a 1h write costs 2× input against 1.25× for 5m, and a read costs 0.1× either way, so one read inside the hour pays for it.

Set it brain-wide in MANIFEST.yaml (promptCache: { ttl: 5m }) or per role (promptCache: { ttl: 5m } in the role file’s frontmatter); the role wins. Who honours it: Anthropic models, direct (anthropic:) and through the Vercel gateway (vercel:anthropic/*). OpenAI GPT-5.6+ (openai:, vercel:openai/*) get cache breakpoints with OpenAI’s own fixed 30-minute lifetime; OpenRouter, NVIDIA and older models get none (they may still cache on their own). CLI agents are not affected — the Claude CLI keeps its own policy.

To see what it costs, add cacheCreation1h (the 1-hour write rate, 2× input for Anthropic) to the model’s pricing in constitution/models.json; without it a 1h write is billed at the cacheCreation rate and under-reported.

9.6 Tool loading — which tools ride turn 1

Your role’s capabilities: decide which tools an agent has (§ 7.3). toolLoading decides only when each of those tools’ definitions is sent — it never adds a tool, and a name outside the grant changes nothing.

  • API agents load every tool on turn 1 while the role has at most deferAbove deferrable tools (default 30). Past that, every tool that is not eager waits behind one search tool, tool_search: the model searches by keywords (tool names count double, descriptions once, top five) and a match becomes callable on its next step. In practice only '*' roles (100+ tools) cross the line. Below 30 tools, loading them all costs only cache reads after turn 1 and keeps the model’s choice accurate; past it, a long tool list costs accuracy and window, which is what deferral buys back.
  • What deferral costs. A turn that needs a deferred tool takes one extra model step (the search), and the step after it sends a changed tool list, which re-writes the provider’s cached prefix once (§ 9.5). The next turn starts from the loaded set again: an API agent re-discovers per turn.
  • eager — tool names this role always loads: its hot tools. On API they are never deferred; on direct Claude CLI they skip Claude Code’s own ToolSearch, beside the engine’s always-loaded tools (questions, grants, memory, skills). Keep the tools this role calls on most turns here — including any its role prompt or a skill tells it to call by name (db_query for a role with state:): a deferred tool called before a search found it fails once and costs the search anyway. Every eager tool is sent on every turn.
  • deferAbove: 0 defers every tool that is not eager. deferAbove: false never defers — for a chain of small models that cannot drive a search tool reliably.
  • CLI agents keep their harness’s own deferral: Claude Code defers everything not always-loaded or eager behind ToolSearch, Codex defers every tool behind its tool_search, OpenCode defers nothing. deferAbove does not apply to them. A Claude CLI role routed through a gateway (claude:openrouter:…, claude:vercel:…) defers nothing either — Claude Code turns ToolSearch off behind a non-Anthropic endpoint — so keep such a role’s capabilities: narrow.

Set it brain-wide in MANIFEST.yaml (toolLoading: { deferAbove: 40, eager: [web_search] }) or per role (toolLoading: in the role file); per key, the role wins — a role’s eager list replaces the brain’s. /system/context and /context show what each role really sends on turn 1 (in prompt vs callable), and the runtime doctor (doctor_check, the dashboard’s System › Doctor — Role declarations) names a role’s eager tool the role is not offered and one engine-tools.json disables (listing a brain-wide hot tool that only some roles hold is what the MANIFEST list is for). An eager name no tool answers to at all — neither an engine tool nor one your brain registers — fails the boot with the nearest real name, and a renamed one fails the load naming its replacement; omnislash doctor → Tool names reports both, on the laptop too. tool_search is reserved: a brain tool cannot use that name.

9.7 Money budgets — what stops work, and who lifts the stop

A budget is a USD ceiling on metered spend (the ledger’s cost_usd_metered — subscription usage is never counted) over a scope and a window. Guide and examples: budgets.md.

ScopeWindowsWhat is summed
brainday, monthevery metered ledger row in the window
scenario (workflow / pipeline)run, day, monthrun: the rows of one execution, sub-agents included; day / month: every run of the scenario
role (*.agent.md, system role-*.md)day, monthevery agent of the role together

day / month are calendar windows in locale.timezone. The tiers (row above) fold per window, nearest wins, and null is a written “no limit here” that overrides the tier below.

What the engine guarantees:

  • Every agent turn is checked before it starts — chat, heartbeat tick, hook fire, workflow task, pipeline runAgent, sub-agent, system agent — against the brain’s limits, its role’s, and its scenario’s and run’s when it runs inside one. A role in a switched-off feature is still under the MANIFEST budget.role default. A budget at or over its effective limit (configured + raises granted for that window) refuses the turn before any model call: terminal failed, errorCategory: 'budget_refused' (BUDGET_REFUSED, @omnislash-ai/types/spend-budget), a message naming the budget, the spend and the limit. The category is the contract — recognise a refusal by result.errorCategory === BUDGET_REFUSED, never by the message. It is distinct from budget_exceeded, a CLI turn that ran past its own cli.maxBudgetUsd (and so did spend).
  • What the refusal does to each consumer. A workflow run parks in awaiting_retry with its completed tasks kept and re-drives later or after a raise; safeRunAgent returns the failed result to your pipeline code (runAgent throws it as a TerminalError); a chat, heartbeat or hook turn ends failed and the next one asks again. Nothing is parked in the agent.
  • One incident card per exhausted (scope, window, windowKey) — a HITL approval, producer budget, to the default HITL chat (TELEGRAM_HITL_CHAT_ID, else the primary owner’s DM; payload.data a budget-incident.v1 record). Every refusal while it is open joins it; a day / month card expires ten minutes after its window ends. +50% / ×2 or a reply naming a higher amount raise the limit for that window only; Cancel keeps it stopped (unattended work raises no new card for that window; a turn a person is waiting on — an owner’s chat message, a message from the dashboard or over the MCP bearer door, not a run or sub-agent they started — asks again). Only a person answers it — an agent, hitl:write or not, is refused.
  • Writes are split by who may lift a ceiling. budget_update (config:write) lowers a limit or adds one — on MANIFEST (brain, or the scenario / role default when no id is given) or on one scenario’s / role’s budget: — and refuses a write that raises or removes one. budget_widen (budget:widen, restricted, a person approves) is that same write without the refusal; budget_raise (budget:widen) adds USD to the current window of one budget and re-drives the runs it stopped, needing no agent turn to be admitted. manifest_update refuses the budget section, and every other write of a role, scenario or heartbeat file refuses a change to its budget: key — role_update / role_create / role_widen / role_write_file, scenario_update / scenario_write_file / scenario_widen, heartbeat_update / heartbeat_write_file / heartbeat_widen, the whole-file raw writes included (a scenario’s or heartbeat’s evaluation: likewise, which is loop_set’s) — so none is a way around budget:widen. The self-evolving loop may not touch a ceiling at all: budget: is a measurement block its proposals cannot edit.
  • budget_status (runtime:read) answers where every limited scope stands: spend, effective limit, raises, and the card that speaks for each window (card: { id, status: 'pending' | 'cancelled' } | null).

What it does not guarantee: a turn already running when the budget is crossed finishes, so N concurrent agents can overshoot by up to N turns; spend outside the model ledger (a paid third-party API your pipeline calls itself) is not counted yet; there is no warning before the stop; a usage.retentionDays shorter than a month drops ledger rows a month (or a long run) window still counts, so those budgets read low and stop late. loop.budget (§ 9.1) is a separate ceiling the loop’s own agents are under as well.

9.8 Feature contexts — what a feature adds to a turn linked to it

A feature contributes text and data to a turn through features/<f>/<name>.context.md (body = instructions; frontmatter = description, entity, when, data, chars, keep). The whole model: contexts.md. What the engine guarantees, and what it asks of you:

  • It attaches on a link, never by itself. when is a closed vocabulary — run (the turn is a run of the feature’s own heartbeat, hook, scenario task or pipeline runAgent), chat, reply, ask (a catalog line the agent opens with context_open), always (every turn of a role that admits it). chat = the turn’s chat or thread is bound to the context: a group rule’s contexts: { <context>: <entity> } in constitution/access.yaml, or context_bind (capability context:bind; the pipeline SDK’s bindContext).
  • A role receives only what its contexts: lists (['*'], names, or nothing = none — fail-closed, like skills:). run and an access.yaml binding bypass the list: the feature tied itself to the role when its entity named the role in agent:, and the group rule tied the chat to the context. reply, ask, code and a context_bind binding are inferred and do not.
  • Brain code may link a turn (code). <id>.context.ts beside <id>.context.md (defineContext({ match, render })), attach: [{ context, entity? }] in the engage() verdict, and { entity } / { reach } in a hook gate’s verdict (carried by the fire’s run link — as are runAgent({ entity }) / ({ reach }) in a pipeline step, which need the invocation’s scenario to belong to a feature). Inferred — the allowlist, the pin and keep apply; it needs no when word. It fails closed: code that does not load, throws, outlives contexts.codeTimeoutMs or answers the wrong shape links and renders nothing, with the reason in the log (contexts.md § 2.3). Doctor Feature context code reports a .context.ts that does not load or has no context.
  • A bound chat pins its context — tenant isolation. In a chat bound to C@e, C attaches with key e only, from any link: another key is refused (context_open says why), a link without a key takes e. An access.yaml binding pins the chat for every bot in it (it attaches only for the bot whose rule names it). Only { view, by } entries are scoped by the key — a view carried whole shows its whole rows in every chat. A context_bind may not contradict the chat’s access.yaml binding.
  • Attached context rides the turn tail, never the system prompt. The system prompt carries only the catalog of ask contexts the role admits — stable per role, so the prompt cache holds. A CLI session receives the instructions once and a view again only when its rows change; an API turn renders it fresh.
  • A view named in some context’s data: leaves every role’s <agent-state> block — it travels with its context only (§ 8, state.md).
  • Nothing is dropped silently. Over budget (§ 9 row), a context’s views are cut by rows, then stubbed; a context whose instructions are over its chars, or one past the turn’s budget (the weakest link first), is a one-line stub telling the agent how to open it.
  • The id is the file name, unique in the brain — a duplicate loads neither copy, reported by doctor Brain entities. Doctor Feature contexts reports what loads but cannot work: a context no role admits and no run reaches, ask without a description, a view two contexts claim, a view or by column that does not exist, a role’s contexts: naming no context, a role admitting an ask context without context:read, a by view on a context whose links carry no entity key (always, or run in a feature with neither a gated hook nor a pipeline).
  • A message the bot sends for a feature remembers it. A heartbeat’s or hook’s post, a hook’s outcome notice, a feature run’s answer in a chat, every HITL card, and a send with about (notification_send, file_send, voice_send, context.notify, a pipeline’s notify()) are recorded with their origin — the feature and the reach the message carries (the run’s reach, each context with its entity key, or the one context the send was about) — at every message each became, so each part of a long text the platform split carries it. A native reply to such a message links the turn that answers it (reply) to the recorded reach’s contexts whose when holds reply, each with its key — the reply sees what the run saw — and the link holds for the context’s keep turns after the reply. about: { entity } alone keys the one keyed context of the sending turn’s own run (refused off one, and on none or several); about: { context } sends about no entity. A run whose reply reach entries all declare an entity: and carry no key must say which one in its send — a send with no about is refused, naming each context and its key — so a reply never comes back unkeyed (contexts.md § 2.1). A send tool refuses an about naming a context that does not exist; a pipeline’s notify() / approval card logs one and keeps the invocation’s run. Engine chrome has no origin.
  • What a turn carried is on the record. Every turn records the contexts its tail carried — each attached one with its link, entity key and rendered size, each stubbed one with the budget that kept it out. agent_context reports per role how often each context attached and was stubbed, its mean size and its links (a stubbed one raises the context-stubbed finding); /context in a chat names what the latest turn carried (contexts.md § 5.1).
  • A context is text, not a right. It grants no tool; context_open itself is capability context:read.

9.9 Widgets — an interactive page a person uses

features/<f>/<name>.widget.html (+ optional <name>.widget.yml) is a page an agent opens for the person in its chat with widget_create. The whole model: widgets.md. What the engine guarantees, and what it asks of you:

  • The page is served verbatim. No ${VAR} rendering — a page goes to whoever opens it, so nothing from your environment reaches it unless your code puts it in input or state.
  • The spec is checked before a widget opens, never defaulted. Unknown keys are refused; a tools entry behind a HITL capability, one that controls agents, writes widgets (widget_* writes) or works in an agent’s checkout (shell, files), or one the deployment’s worker does not have refuses widget_create; a spec that does not load keeps its widget closed. What an open widget may reach (tools, origins, permissions) is frozen when it opens: editing the file never widens a live page.
  • A submit is an inbound event — source widget (reserved: event_ingest and defineEventSource refuse it), event widget:submitted, resource widget:<id> plus any resources you passed. Its sender is the person the engine verified, on the event’s actor; what the page itself sends is fenced as untrusted text — in a turn and in widget_get alike. One page key from one person = one event.
  • Destinations are closed. widget_create posts only into the calling turn’s chat, a channel your config declares, or one person’s private chat with your role’s bot (to: 'tg:user:<id>': the launch only for a person your turn serves; anyone else gets no launch message, and the page admits them only as that chat’s rule does until widget_widen names them) — never a free-form group id. A turn with no chat that serves the dashboard or an MCP client opens a widget with no chat for that principal instead: no launch message, used where that principal is (widgets.md § 13–14). Widening who may use a widget is widget_widen, a person’s decision.
  • A turn only where the answer lands. With deliver: turn the page’s submits come back to the agent that opened the widget as ONE turn per burst (debounceMs, at most maxWaitMs), under that opening turn’s own chat, principal and capability ceiling, fenced as untrusted text, and the answer goes to the widget’s chat. That holds only for the chat’s own agent opening the widget in its chat — or for any agent opening a widget with no chat, whose turn is read where that agent’s turns are; one opened by a pipeline, by a hook’s code, into a declared channel or by an agent that does not serve the chat (a heartbeat’s, a sub-agent) delivers events — unless it asks for deliver: chat: then each person’s submits become a turn of the agent serving the widget’s chat, run as THAT PERSON (their principal and ceiling, re-admitted at delivery, one person per turn), answered in the widget’s chat (widget_create says which it got). A delivered turn also carries, outside the fence, the record of what moved the page since that agent’s previous widget turn (up to maxTurnContextBytes): events and media one line each, state replacements collapsed into one line, each naming the tool whose code pushed it — only what that agent and your code wrote through the engine (widget_update, context.widgets.push), never the page; what another agent wrote (the heartbeat that opened a deliver: chat widget) comes fenced, its author named. The state itself never rides a turn: it is the page’s data and may carry what people typed, so widget_get answers it fenced (stateContent). An event never carries person-written text. A message the person sends from the page runs as that person, never as the agent that opened it.
  • The page runs contained. It is an MCP App the engine hosts: a sandboxed frame on the content origin (artifacts.<domain>) with no storage, no cookies and no reach into Telegram or the session — it talks only to its host (/sdk/widget.js, or the official MCP Apps SDK). Its network is the spec’s csp.connectDomains and nothing else; images, media, fonts and scripts load inline, from data: / blob:, from csp.resourceDomains or from the media you granted — in widget_create’s media, or later in widget_update’s (URLs that work only while the widget is open) — or made for it: voice_render({ widget }) / image_generate({ widget }) and your code’s media bytes become the widget’s own file, at most maxMediaBytes, gone when it closes. The page that opens is the version the widget pinned: a file edited since is not served.
  • Code pushes to its page. Every tool body gets context.widgets: push(widgetId, { state?, event?, media?, chat? }, { tx? }) and page (the widget whose page made this call, else null — gate a page-only tool on it, never on context.auth.widget). With your tx the push commits or rolls back with your writes and holds the widget’s lock until you commit (push last); state is a function called under that lock. Who may push is judged on the caller: a page its own widget and same-opener siblings, an agent the widgets it opened, brain code (a pipeline step, a run) any, an MCP session with no agent any an MCP session opened, the dashboard any, the webhook door none — the same rule holds widget_update. Closing or widening (widget_close / widget_widen) reaches further: an agent whose turn serves an owner and any MCP session close or widen any widget, a HITL card’s page only with its card (widgets.md § 5). It answers an ack (ok, dry-run under a loop replay, refused with a code and reason) and throws only for a malformed call; passing db instead of tx compiles and throws at the call. Never return a state for the agent to relay.
  • What the page did reaches its chat only as your code’s lines. A push’s chat: [{ text, echo? }] (at most 20, each cut at 1000 characters) commits with it and is posted by the bot into the widget’s chat — a widget’s consecutive lines one silent message headed 🧩 Widget · <title> — ahead of a later turn’s answer in that chat, at least once. A line is the brain’s, in the bot’s voice; echo: true quotes the person whose page gesture made the call (only pushed to that page’s own widget, page — else the push throws), named by the engine; never put a person’s words in a line without it. Skipped, never refused (the ack’s chat): a widget with no chat, a business chat, policy transcript: off. A page message shows in the chat as the person’s quote, above its answer (transcript: off keeps a group widget’s page messages out). A reply to a lines message reaches the agent fenced, as the page’s — never as its own words.
  • A gesture from the page is the person’s — whoever its host verified. On the dashboard the person is the dashboard’s login, which reaches every widget; in an MCP Apps client (Claude, ChatGPT, VS Code) the session’s MCP principal, at a widget with no chat that names it — your widget files are its ui:// resources (_meta.ui from the spec), each shown through its own tool, the page’s calls app-only tools, every other tool model-only, so a foreign host’s page reaches tools only through its tools list. In Telegram the page signs in with Telegram’s launch data, checked against the bot that posted the widget and no older than authMaxAgeSeconds; only someone in the widget’s audience (the person it was opened for — in a business chat the counterpart —, the owners, whoever widget_widen added) gets a session, decided by the widget’s own chat wherever they open it from, and each person only from the chat they first opened it in. In a DM, the member tier means the DM’s own person; in a group, everyone its rule lets talk there whom the platform shows in the group. A submit, a message and a tool the page calls run as that person — a tool only from the spec’s tools, never past what the agent that opened the widget may do (none once that agent is gone) nor past the person’s own ceiling in the chat, never one that needs a person’s approval, controls agents, writes widgets or works in an agent’s checkout; a tool call runs in the worker for at most toolTimeoutSeconds, and a failed one shows the person only the tool’s own classified refusal (refusal(message)), else an engine sentence — never a plain failure string. Submits, messages and non-read tool calls count against submitsPerMinute per person; each is recorded in the action log against the widget. A frame that navigates away from the page ends the session.
  • Opening one is a capability. A role opens widgets only with widget:create in its capabilities:; widget:widen asks a person on every call. A draft (widget_create({ draft }) — a page not written into a feature yet) whose spec widens the page (tools, permissions, csp domains, policy) also needs feature:write. A widget opens in any chat the bot talks in — in place in a private chat, through a link to the bot elsewhere (one tap with the bot’s Main Mini App registered in BotFather, two without; doctor Widget launch) — and only on a deployment with https origins (doctor Widget hosting). menu: true makes it a person’s menu button.
  • A HITL card’s page answers that card and nothing else. An approval action with widget, and every input request’s form (the engine’s ui://engine/form unless you name a page), open a widget bound to the card: its page answers the card as the person at it, through the same gateway as any button — only someone who may decide the card from a chat may decide it there; an approval’s data is held to its responseSchema (the review decision’s by default for ui://engine/review, and required on the page’s own action), an input to its inputSchema — and the widget closes when the card is decided anywhere; it has no expiry of its own, and widget_close / widget_widen refuse it. An agent’s card keeps that agent’s ceiling over the page’s tool calls. A card’s page is a ui:// widget file checked at request time (it must open, its input must fit inputSchema and maxInputBytes; its policy: tier applies), never a publication. widgets.md § 12.
  • A browser signs in with Telegram, and only admission lets it in. Where the deployment offers it (a login bot with its Client Secret in loginSecretEnv, widgets on a hostname of their own — doctor Widget browser sign-in names what is missing and the exact BotFather URLs), a widget’s link opens in a plain browser after a Telegram sign-in: the person meets the same admission as inside Telegram, plus the chat rule’s browser: (audience · owners · off; a business chat off unless a rule opens it; opening it is access_widen). A session is written only for a person a widget admitted, lasts 30 days, and a HITL card’s page asks for a sign-in younger than authMaxAgeSeconds — at sign-in and at every renewal and call of an open page. widget_list_sessions lists them, widget_revoke_sessions (access:write) ends one or a person’s all, and blocking a person in access.yaml ends theirs in the same write. widget_create({ to: 'tg:user:<id>', launch: false }) opens a page for one person to hand over by link (widgets.md § 15).
  • A preview runs nothing for real. widget_preview renders a page in the real host under its own CSP and shows the calling agent alone its screenshots; it opens no widget and reaches no person, its page tools answer only the caller’s mocks (a tool off the allowlist refused as live), its requests to connectDomains only mocks — resourceDomains alone are fetched, by the engine. A publication (artifact:) or media needs runtime:read. Without the deployment’s browser service it answers preview unavailable: …, never a failure — widgets.md § 16.
  • Lifecycle is explicit. open until a close — yours, the TTL, the per-agent cap (superseded, only once a newer widget’s launch went out), a launch that could not be posted (failed) or, for a card’s page, its card decided (resolved) — then closed for good, with the reason on the row; past expiresAt it takes no submit or push even before the close lands; retentionDays after closing it is deleted with its pushes. A retried widget_create with the same idempotencyKey (the pipeline SDK sends one per step) answers the widget it opened, as it opened it — its chat and its launch message or none, whatever the retry asks —, never a second one.

10. Task output — one envelope, and the honest-failure arm

Every task of a workflow run is dispatched with the same structured-output schema. Not “a schema when the planner produced one, otherwise nothing” — every task, always:

{
  "success": true,              // the agent's verdict
  "error": null,                // the blocker, verbatim — required when success=false
  "summary": "...",             // what it did, or how far it got before the blocker
  "artifacts": [                // null when the task declares no output artifacts
    { "filename": "report.md", "sizeBytes": 4096 }
  ]
}

artifacts is a self-report, and the engine never verifies it. The list is a forcing function and nothing more: to name a file’s size in bytes the agent has to measure that file, which means it has to have written it. Nothing reads sizeBytes afterwards — no engine consumer, no check compares it to the file on disk. What IS enforced is separate and independent of the envelope: every filename in the task’s declared outputArtifacts is looked up on disk, and a missing one comes back as one recovery re-prompt, then fails the task. So do not build on sizeBytes — a hook, a report or a query of yours treating it as a measurement is reading a number nobody checked.

Two things follow for you.

10.1 The deliverable is the FILE — deliverable.schema checks that file

New to the deliverable — what it is, how you declare one, which task writes it, how it reaches a user? Read deliverables.md first; this section is only what the envelope changes about it.

The envelope carries no payload of yours. There is exactly one delivery channel for every format, JSON included: the file the agent writes at deliverable.filename. That file is what the engine reads off disk at the end of the run and what reaches your user; nothing copies it into the report.

  • deliverable.schema is a check on the file’s content, not a generation constraint. For format: json, the engine compiles your schema and validates the file after the task’s turn. A mismatch — or a file that is not valid JSON — comes back to the agent as one recovery re-prompt naming what was wrong; if the second turn still does not conform, the task fails with that reason. Your schema is applied verbatim: nothing is stripped and nothing is tightened, so an open object stays open and your own constraints still bite.
  • Your schema is not sent to the provider, so it spends none of OpenAI’s 5-level nesting budget and is not held to the structured-output safe subset.
  • It degrades quietly when it is not a schema. Invalid JSON, a non-object root, or a document the validator refuses: the structure is still described to the agent as text in the prompt, and no check runs. The prompt promises a check only when one will actually happen — with one exception: a schema that compiles but throws while checking (a self-recursive $ref, a $dynamicRef) has already been promised, and the file then ships unchecked. Keep the schema non-recursive if you need the contract enforced. Non-JSON formats (markdown, text, …) are never content-checked — their schema is a prose outline. See ./deliverables.md.
  • storage does not change this. inline / blob / none picks where the file is persisted, not whether its declared structure is a contract — the check runs the same for all three.
  • workflow_task_runs.result is the agent’s prose summary — the human-readable record of the turn, never your deliverable. Same for anything of yours reading an agent’s structured output directly (a hook on agent:completed, a tool that inspects a finished task): the envelope there carries no payload of yours either. Read the deliverable from the run’s deliverable column, or from the artifacts dir.

10.2 A task may answer with a failure, and the engine believes it

success: false with the blocker in error is a first-class ending, not an error path:

  • The task fails immediately with the agent’s own reason. No retry and no re-prompt — the engine neither second-guesses the verdict nor classifies the agent’s wording (a blocker that happens to mention “rate limit” must not be mistaken for an infrastructure hiccup and re-driven).
  • Whether that fails the run is still your declaration. An optional: true task absorbs its own failure and the run continues; a required one fails the run — and on the deliverable task the run fails before the pull-request finalizer runs.
  • The reason is stored on the task row and carried on the workflow:task.failed event, so it is the text your failure notification and the diagnosis agent see. Write task descriptions whose blockers make sense out of context.
  • Do not prompt against this arm. A scenario, role or planner-stage instruction along the lines of “always finish successfully” re-creates exactly the failure mode the arm removes: an agent that hit a wall — missing access, contradictory requirements, a dependency that does not exist — cornered into inventing findings or writing an empty placeholder file just to satisfy a success-only schema. A reported failure with a real reason is a valid way to finish.
  • A turn that returns no envelope at all is not a failure verdict — it is an unreadable turn. The engine re-prompts once for the report, and fails the task if the second turn is unreadable too.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.