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 underbrain/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-nodestep in the engine-generateddeploy.yml/ci.yml, which writes a scoped npm userconfig from theNODE_AUTH_TOKENsecret; - for children the CLI spawns —
omnislash init/upgraderesolve a token from your environment or~/.npmrcand hand theirpnpm installa process-local, owner-only userconfig.
Where the token comes from: brain-dev-guide.md § Setup.
Why one declaration is enough.
| Context | What pnpm does |
|---|---|
| Image build, version range satisfies the workspace member | Symlinks 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 member | strict-peer-dependencies=true fails the install with a clear “unmet peer” error. Build aborts — correct: a version mismatch should fail-fast. |
| Local install, peer missing | auto-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 all | strict-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 upgraderegularly. 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:
| Mode | Meaning | Source of truth | Updated by |
|---|---|---|---|
| G | git-backed; writable in prod | brain git remote (single source of truth) | dashboard, agents, brain-sync daemon (continuous pull+commit+push loop) |
| R | read-only in prod; image-baked | brain git remote at image build | dev → git push → CI rebuild → deploy |
| W | writable, host-bound, no git | runtime 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):
| Layer | Source | What |
|---|---|---|
| Engine baseline | engine-fixed | memory/, 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 extension | ProjectConfig.brainSync.additional | Whatever 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.runtimeOnly | The 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):
| Path | Mode | Source | Notes |
|---|---|---|---|
memory/ | G | engine baseline | Long-term memory + memory logs. The worker autonomously writes daily files. |
constitution/ | G | engine baseline | Configuration 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/ | G | engine baseline | Two 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”: {" |
features/<name>/ | G | engine baseline | All 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/, … | G | brain extension | CLI 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/ | R | image bake | Brain-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 excluded | R | image bake | Brain 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/ | W | host bind | Run 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 bypnpm installat 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 beforebrain-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 needsworkflowscope 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 site | Hot-reloads via brain-sync | Needs omnislash build |
|---|---|---|
features/<name>/*.pipeline.ts imports | Edits 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|node | SKILL.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:
*.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’sonCodeChange— what is safe to change under them and what happens to the runs an edit breaks: pipelines.md § Editing a.pipeline.tswith in-flight invocations. A reload whose module does not load is reported, and never takes a scenario that serves now offline (§ 5).- Everything else a process imports at boot — not hot. The helpers a pipeline imports (
./x.ts) and every otherfeatures/*.tsthe engine imports at boot or through another module — modules youromnislash.config.tsreferences (tool handlers and the like), and the files a*.hook.tsgate or a*.loop.tsmodule 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.tsgate and the*.loop.tsmodule 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. - A push is a deploy. Any push to
mainthat touches more than runtime-only paths (§ 2:.md,memory/,brainSync.runtimeOnly) runsdeploy.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.tswhatever 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
runcwd = a per-rungit clone --sharedat$RUNS_ROOT/run/<runId>/brain, branchedwf/<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 ownrun_inpoints (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_compactandagent_flush_mailboxrefuse a turn while a non-terminal run still owns the agent (an active interception exemptsagent_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 agentscommit && pushto persist; the finalizer merges back. - Standalone
agentcwd follows the persistedagents.run_in(chat agents, heartbeats, hooks, a single-agent micro-brain) — theRunInladderlive ⊂ clone ⊂ sandbox, chosen when you create the agent (telegram wizard, the dashboard’s New session, oragent_create), declarable in*.heartbeat.md/*.hook.mdfrontmatter, and overridable per-dispatch:-
clone/sandbox→ agit clone --sharedunder theagentscope ($RUNS_ROOT/agent/<agentId>/brain, branchagent/<id>); the agent persists viagit add/commit/push, same as a run task.sandboxadds a bwrap jail (needsisolation.enabled) — and a CLI harness: the jail is applied when the engine spawns the CLI subprocess, so an agent whosemodelRefresolves to an API model runs unjailed on every tier. A bare role (smart/fast) resolves API-first; writecli:smartwhen 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 thebrain-syncdaemon, so it must NOTgit 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-levelisolation.enabled. Persistingsandboxrequiresisolation.enabled;cloneis always available (it just falls back tolivewhen 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/brainis ephemeral (lost on redeploy) — delegates the edit to a sub-agent spawned withrunIn:'clone', overriding that sub-agent’s row default for the one turn.runInselects the cwd independent ofisolation.enabled:'live'(default),'clone'(its owngit clone --sharedwith a pushable remote, no bwrap — the sub-agentcommits + opens a PR, the human gate),'sandbox'(clone + bwrap jail for untrusted code). Thedurable-repo-editskill teaches the clone→edit→commit→PR procedure. One engine agent runscloneand pushes the branch directly: thegit-conflict-resolverthat lands the daemon’s exportedbrain-sync/conflictcommits — 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 arun_inyou set by hand in the dashboard is gone by the next tick. Theagent: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
clonewhenever 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
cloneoversandboxhere unless you specifically need the jail.sandboxrequires projectisolation.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 asandboxdeclared 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 aclone/sandboxthat would merely degrade for want of a baseline as a warning. -
Never write into the baseline (
$BRAIN_BASELINE). It is owned by thebrain-syncdaemon, which pushes whatever branch it is on — an agent that leaves a topic branch there stops brain sync for everyone.cloneexists precisely so an agent never needs to reach for it. -
A scenario’s
agent:block is run-scope, sorunInthere 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). Useexecution.pullRequest: truefor the PR path.omnislash doctor(“Agent runIn”) flags arunIndeclared where it can’t take effect too. The same holds formemoryin that block — a task agent’s memory is its dispatched role’s own — andomnislash doctor(“Agent memory”) reports it.
-
/app/brainis not the agent cwd in prod when sandboxed, and in prod it is not a git repo (composite R-set +brain_statesubpath views). For isolation-off agents the cwd IS/app/brain(G-set subpaths RW-mounted frombrain_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 ownskillslist andagentspec 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, thecontextpolicy,memoryFlushandmemoryKeep, 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’sload-blueprintstep — 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_deleteanswer “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 bymarkSpawnedBy— 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 besiderunId, so they fail the same way rather than falling back), its failure spawns no remediation agent (RecoveryAgentSpawnerskips it), and it is never retried (submitRetryon 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 isresolveCheckMode(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)withtargetKindaLoopTargetKind—scenario,heartbeatorrole(@omnislash-ai/types/entity) — so a scenario that gains a.pipeline.tskeeps its cycles, lessons, ideas, holds and eval window;evaluations.target_kindis generated fromrun_kind(both engines’ runs grade their scenario) and the eval window is indexed on(target_kind, target_id, created_at).RunKindstays 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 isawaiting_decision(CYCLE_STATUSES,@omnislash-ai/types/execution-status, each with its reader label inCYCLE_STATUS_LABELS). - A cycle is a line of rounds, and a round owns its PR.
loop_proposals((cycle_id, round)unique,parent_idself-FK) carries what one round proposed (suggestions,summary, therefinementthat asked for it), itsstatus(proposed → accepted | skipped | refined, thenapplied|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 — andlanded_files, the paths the PR carries, both written bymarkApplied),revert_pr_url(+revert_merge_gate, the gate the undo stands under, andrevert_outcome/revert_merged_atonce the wait on the revert ends) orrevert_error(why a change owed an undo has none — the change is still live),checked_sha,check_run_ids/baseline_run_ids(both GIN) andcheck_verdict. A refinement is a CHILD round, never an overwrite —decideProposalmoves the parent torefined— and a retriedfailedround returns toacceptedon its own row.findCycleByCheckRunIdanswers{ 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 carriesproposalIdbesideprUrl. 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_atis 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(inrecord-analysis),decideProposal(ingate’sdecide-proposal,decided_by= the round card’s HITLrespondedBy), andmarkApplied/recordCheckedSha/recordCheckRuns/recordCheckVerdict/recordRevertPr/recordRevertOutcomeon the round standing now;recordMergeGate/demoteMergeGateon the cycle. What a person owes the loop — every gate kind — is one statement,listCyclesAwaitingPerson(rearm); the breaker’srearmentities 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 besideloop_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) isdeclaredJudgeat the freeze: the pinnedmodelRef(judgeModelRef),methodRef/methodHash, andjudgeDigests—loopModuleHashplusevaluatorRoleHash, the digest of thesystem:evaluatorrole file that resolves (brain override, else template), whose body is the judge’s own part of its system prompt — the role file only; the sharedevery.md/with-*.mdfragments composed around it are not pinned, so an edit to them mid-cycle goes undetected.evaluatorRoleHashis.optional()on the wire because an eval job or a jsonb row written one deploy back lacks it; absent compares as its own value injudgeDrift(matches only absent), so such a grade is never counted beside one that states a hash. A pinned grading (EvalService.resolveJudgewith a frozen snapshot) dispatches under the frozen ref and never callsjudgeModelRef— 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 readsjudge: 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 BullMQUnrecoverableError(booking settledfailed, no retries); doctorEval judgesruns the samejudgeModelRefper entity.measureBrainMetricsreturns the digest of the module that measured beside its values, and a digest that is not the grade’sloopModuleHashfails the measurement. History is the lastloop.check.inputsdistinct inputs among the entity’s graded runs through the window’s anchor — the evaluation ofloop_cycles.window’s newest run; a window naming no runs freezes golden cases only (the read scansHISTORY_SCAN_LIMITwide, 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.evidenceRunIdsis the same scan’s head,evidence.runsdeep — 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 namesheld-out k of nandk = 0is a doctor hint. Excluding every run the loop itself launched (created_by.kind = loop, and any pinnedcontent_sha) but not the runs it merely observed; golden cases come fromdefineLoop({ cases });baseShaisorigin/<default>as the remote holds it at the freeze (remoteBranchTip, read under the engine repo’s own credential, no fetch). An input’sincumbentnames 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 atbaseSha— one rule, no knob.loop.check.modeis never persisted: a NULLfrozenis the record that the cycle has no pre-merge check, which is whatresolveCheckModederives for every heartbeat and pipeline and for a workflow whose plan needs a capability a human has to grant, reaches abovemoderate, opens a PR or runs in an attached repo (agent.repoother than the brain), unless a tier declaredreplayon 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 (casesmalformed, a declaredreplaywith no readable plan, a remote with no default branch) settles the proposalskippedwith a notice, like an unresolvable judge; a transient failure on the way (the DB read, thels-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 isapplied— launched by theshipphase, waited on by thecheckphase:resolve-pr-headrecords the PR head as the round’schecked_shaand sets theomnislash/validationcommit status to pending on it (setCommitStatus,gh api, under the engine repo’s own credential —statuses: writeis 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 onelaunch-<i>-<t>-candidateper frozen input percheck.trialssubmits the scenario pinned to the head, and onelaunch-<i>-<t>-incumbentpinned tofrozen.baseShafor every input whose frozen entry carries no comparable grade, each under the deterministic keycheckReplayKey(proposalId, i, t, side)(a relaunch answersduplicate); the move tocheckcommits the launches; the wait is onecollect-checktick per ring over the replays’ rows and grades up tocheck.deadlineHoursfrom 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 (judgeMatchesagainstloop_cycles.judge, re-read inside the tick); a row re-graded under another judge while the check polls isungraded. The replay’s eval job carries that judge by construction:enqueueRunEvalrefuses aloopactor whose cycle has no pinned judge with aTerminalErrorbefore 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 aPairSide), reading every metric as the median of its per-input movements, viability input by input, and acriticalmetric as a per-input veto;VerdictResult.perInputrecords 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 tochecked_shavia--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;mergePrAutoanswersarmedorunavailablewith GitHub’s reason and NEVER merges directly —unavailabledemotes the round to human with anauto-merge unavailablenotice; themergephase 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_idongithub:pr:<repo>#<n>; an event is a cue to re-read, never the answer, so apull_request.auto_merge_disabledonly makes the read happen sooner) — and demotes to human when the read says the arm is gone). The apply publishesomnislash/diff-gate: successon the pushed head BEFORE it opens the PR (status-diff-gatebetweenpush-branchandopen-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 failedgh apipost the loop asksprobeStatusesWriteunder the same credential, and a 403 that is not a rate limit is terminal while a rate-limit 403, a 5xx or no answer keepsSTEP_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 assuccesson 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 settlesregressedfromappliedwith 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 isobserved by ONE function per tick:watch.ts(S9) readsloop.watchlive (read-watch-mode;mode: nonesettlesunvalidatedby 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 itswindowatphase: 'watch'(since= the watermark;repair: the firstwatch.windowRunsruns 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 asrejectedand named in a negative settle reason), writes that window on the cycle aswatch_window(the window’sprogress— the sampler’s own{ have, need, unit },graded runsunder both presets — carried as answered: the eval-forcing producer forces grades while it is short,have < needor absent, and the dashboard andloop_statusread how far the watch has got off it, never offrunIds.lengthorwatch.windowRuns; the next cycle’s mark is itsto), then asks itscompare({ targetKind, targetId, spec, before, after, terms })with the cycle’s window resolved as the brief resolved it and the observed one just sampled.nullis “not yet”: park on the WATCH ladder, ask again on the next ring, untilwatch.deadlineHourscloses the windowunvalidated. AVerdictResultsettles the row on the spot (db-settle-verdict, one CAS statement carrying the record aswatch_verdict, the window’s runs asvalidation_run_ids, underrepairthe terms it counted by asvalidation_terms, and the tally) and is announced with itsreasonandperMetric;regressedtakes the revert path.repair’scompareis the trigger’s question over the observed runs (triggeredWithin— the k-of-n count, or the entity’s ownshouldOpenasked at every grade where a tier declared one):regressedthe momentwatch.minFailuresare counted or the rule fires, whatever the rest of the window brings;validatedon a full window with neither;nullwhile the window is short — itsperMetricis always empty, which is how the breaker reads anunvalidatedunder it as blind (D15:unvalidatedis blind only whenwatch_verdictis null or itsperMetricis empty). Judge health is the ENGINE’s read in the same step (observedHealth, the same scan over the same span, independent of whatwindowanswered): a verdict is withheld while the judge is overmaxUngradedShare, and at the deadline (or once the window is full) that settlesunvalidatedviareleaseUnvalidatedwith no record. Brain code cannot wedge the cycle:windowandcomparerun inside the tick step under its 60 s inactivity timeout, and a throw from either —TerminalErrorincluded — becomes afaultthat settles the rowunvalidatednaming the function (settleUnvalidatable); only the engine’s own read failing underneath them (EngineReadError) is rethrown so the step retries. The round keeps the check’scheck_verdictthrough the settle. Nothing is re-executed. - A round’s change has one of two authors.
loop_proposals.suggestionsholds 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 }],nulldeleting 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(proposercolumn) and the dashboard. Columns: self-evolving.md § Reading the history. - A proposer is your pipeline, called by the loop.
loop.proposer.pipeline: <pipeline id>(enginenull= the proposer agent;MANIFEST.yaml; entity frontmatter, wherenulltakes the proposer agent back) names a servedrestate.servicewhoserunhandler the loop sends aProposerBrief— the proposer agent’s own evidence as typed JSON (the entity’sdefinition, thewindow, the openinggrade, theruns,metrics,lessons, theproposalsline and therelatedFailures— every document the proposer agent reads, as the value its file serializes — andinstructions, theloop.proposer.instructionsof both tiers kept apart), bounded byloop.evidenceandloop.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 mostreviewDeadlineHours, 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 ownctx.runRef): it acts on nothing and can ask nobody. It answers{ summary, hypothesis, files, citedLessons? }ornull;null— or no return value at all — settles the cycleskipped(no round, no card, no lesson, not a person’s rejection), a shape the engine refuses, a reply that is not JSON or aTerminalErrorfails 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.tsof every proposer the loop names — no author edits itself), then the same PR, check, watch, revert and lesson. The proposer agent’s ban on READINGconstitution/Evaluation/**and*.loop.tsdoes not bind a proposer pipeline: it is your code; those paths stay closed to its writes. An id the brain does not serve fails doctorLoop proposersand 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 thesettlephase for every terminal that teaches —validated | regressed | unvalidated | skipped | failed— in onewrite-lessonstep, frombuildLoopLesson(no LLM):hypothesis(the round’s summary; a proposer pipeline’s ownhypothesis),files(the suggestions’ targets plus requested skill dirs; a proposer’sfiles[].path),failure_categoryoff the cycle’s baseline evaluation,deltasfrom the watch’swatch_verdict.perMetricwhere it measured anything, else the round’scheck_verdict(underrepairthe watch measures nothing, so its lessons read the check’s). A terminal with no round writes none. The same step moveshelpful/harmfulin SQL on the round’scited_lessons, and only behind an insert that actually landed, so a re-run step cannot double-credit;helpfulonly when the validated verdict was measured (strengthpaired/measured),harmfulon every regression. Recall isrecallLessonsoverfindLessonRecordsForTarget— each lesson with its round and cycle, so it carries itsproposer,rejectionandstrength— what a person turned down first, then newest first, bounded byloop.lessons.maxRecalledafter 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.searchMemoryare 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_idUNIQUE →loop_cycles.idON 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 pastwindow.ready,shouldOpenand the recurrence gate, never pastenabled, 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 theskippedwrite); status is derived (withdrawn·queued· the bound cycle’s status), never stored. Every surface — theloop_create_idea/loop_cancel_ideatools (capabilityloop:analyze,hitl: trueby default) and the/ideachat command — goes throughpostLoopIdea/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 readsstrength:paired(the pre-merge check passed and the watch measured),measured(the watch alone — a per-metric reading orrepair’s count),blind(nothing measured it — the breaker’s “unmeasured”); onlypaired/measuredcredit a lessonhelpful. 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, capabilityloop:read—safe, no grant) answers one row per cycle, newest first, filtered bytargetKind/targetId/feature/since, bounded bylimitand paged bycursor: the change, its merge and revert, the check and watch records, the strength, the rejection, the lesson. Call it from a pipeline withcallTool; 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) isloop: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 isloop:supervise(hitl: trueby default — relax it inconstitution/engine-capabilities.json, or grant it to the pipeline that supervises):loop_rearm(the dashboard’s Re-arm as a tool),loop_release, andloop_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_widentakedryRunand answer each file’s diff.loop_join(the dashboard’s Turn on) andloop_set(one knob at an entity or brain-wide) areloop: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_setalso 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 isloop_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_joinswitches a paused entity’s ownenabledback 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 undermerge: humanan agent must not merge one.loop:configureasks nobody and can spend (budget, analyses a day, sampling): sethitl: trueon it inconstitution/engine-capabilities.jsonto 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) orjudgedwithhuman: true(people over the judge) is rated on itsscalethrough 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), andeval_rate— an agent relaying the rating of the person its turn serves (capabilityeval:rate,moderate,hitl: trueby 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: autothe 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 doctornames 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:
| Value | Engine catalog | The CLI harness’s own Skill tool |
|---|---|---|
engine (default) | yes | denied |
harness | no | available |
both | yes | available |
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:
COPY brain/package.jsoninto/app/brain/. Does NOT copy brain’spnpm-lock.yaml— the image is decoupled from your local lockfile.- Adds
- brainto/app/pnpm-workspace.yaml(idempotent). - 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:
- Install-time (primary):
strict-peer-dependencies=truefails the install when a peer pin and the engine workspace member disagree. Image build and local install both abort with a clearERR_PNPM_PEER_DEP_ISSUES. Mismatches never reach runtime. - Install-time (structural):
omnislash doctor’sBrain package.jsoncheck fails on any@omnislash-ai/*key outsidepeerDependencies— the gapstrict-peerleaves (regular deps aren’t peers). Runs viaomnislash build/omnislash start. - 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):
- Gate (before the host):
omnislash doctor --check pipelinesin the generatedci.ymland indeploy.yml’s gates job — a red check blocks the deploy. - 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 buildgoes red naming each module and its error, and thePipeline Build Failedalert fires after 5 minutes. Fix or revert and push: a fixed*.pipeline.tsreloads and clears it; a fixed helper clears at the next pipelines restart (§ 2.1 case 2). - 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 ofbrain-example/. The scaffolder emits a registry-pinned, standalone-installable brain in the § 1 peer shape.brain-example/is an engine-internal fixture: itspeerDependencies: { "@omnislash-ai/*": "workspace:*" }only resolves inside the engine workspace, so a hand-copy breaks withERR_PNPM_NO_MATCHING_VERSIONon the first standalonepnpm install. omnislash.config.tsmust never importenv. The config file is committed and public;envcarries secrets. Mixing them leaks secrets to git. No linter enforces this yet — be deliberate. Read private values viaenv.*in yourtools// brain code, not in the config file.- A missing
skills/.settings.jsondoes NOT disable your skills. It holds explicit enable/disable overrides only; a skill with no entry runs on its frontmatterdefaultEnabled(omitted = enabled). No post-deployskills enable --allstep — 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 upgrademoves 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
ownerand declared notification channels, approval cards with no chat of their own, a scenariochatbot.surfacethat names no bot, restart notices and Grafana’s alerts (with monitoring on) are sent by the bot whose role ischief.role(a bot that omitsroleis that bot). The token var it reads does not matter:TELEGRAM_BOT_TOKENis required only when a bot’stokenEnvnames 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’sSystem messagescheck fails. One exception: what an agent of a persona role (a role a bot runs that is notchief.role) writes to the primary owner with no chat of its own — theownerchannel,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 }
}
toolskeys are exact tool names,categorieskeys are exact tool categories (shown on the dashboard’s Permissions › Tools),defaultis the baseline for everything unmatched. Unmatched tools keep the engine default (most ship enabled; a few, likequeue_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 iscreateTool({ agents: false }), notenabled: false: it keeps the tool callable atPOST /tool/<name>(with its schema, capabilities and metrics intact) while stripping it from every agent tool set and from the MCPtools/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": falsepolicy 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 throughdefault). Read it after an engine upgrade. - The escape hatches get no special treatment here: disabling
hitl_request_grantremoves 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 for | Grant is scoped to | Default lifetime |
|---|---|---|
a heartbeat (*.heartbeat.md) | that heartbeat definition | 30 days |
| a scenario (workflow / scheduled plan / pipeline) | that scenario | 24 hours |
a hook (*.hook.md) — any agent its fire started (default agent.scope: fire) | that hook definition | 30 days |
an agent — a plain chat or MCP agent turn, or a chat-scoped hook’s (agent.scope: chat) agent | that single agent | 24 hours |
| none of the above — a bare principal (the MCP bearer door) | that principal — standing, covers every session of the key | 30 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.mdor*.hook.mdfile 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_grantcan name any capability in your catalog, held or not: a chat agent whose role has no shell can ask forengineering:execute, you approve, and it hasBashfor 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’scapabilities:(§ 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 toolengine-tools.jsonswitches 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 ignoreengine-tools.json. An MCP session’s grant (a standingsubjectgrant) 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 orcapabilitiesargument beyond that is cut down, and the child asks for the rest with its own card.agent_updaterefuses to give another agent a capability the caller does not hold, a model whose harness ignores the grant, or a less confinedrunIn(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(andagent_release, which hands the agent back) carries capabilityagent: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_sendis how the interceptor talks to the agent. - Starting Boss mode asks a person.
boss_start/boss_stopcarry capabilityboss: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 addsboss:execute(andboss:readforboss_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:writecan 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 itsapprovableCaps. Agents also cannot press the bot’s buttons through your Telegram account:telegram_press_buttonrefuses 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": falseinconstitution/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:readgrants nothing — the gate never asked. Usually it means you meant a different capability name. ttlSecondshas 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.maxCallsis 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 itsmaxCallsare 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 itsfilter:.- 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 agrants: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 §
contextis the brain-wide value; a role’s owncontext:block overrides it key by key — a role that namescompactAtkeeps MANIFEST’snotifyAt.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; doctorContext marksnames 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 whichrole_trimproposes a deferred tool fortoolLoading.eager. They only change whatagent_contextandrole_trimreport, 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. compactAtnever 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:conversationis the history rewrite —compacted(with how many messages folded and the context size before/after),nothing_to_compact(with why), orfailed(with the error) — andsessionis the CLI’s own compaction,not_applicablefor 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 acontext_compactionitem so a chat UI can show progress instead of a frozen screen, and the final outcome rides the turn’sturn.completedevent ascompaction. - A chat
/compactis a chat operation.agent.sendCompact(opts?)is the chat counterpart ofcompact(), the waysendis ofenqueue: 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’sturn.completedascompaction. The Telegram/compactcommand, the/agentsCompact button and the dashboard/compactall use it. Usecompact()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 knobenqueue/call/sendtake. Add{ wait: true }when you need the outcome or your next step needs the folded context: it holds until the compaction turn finishes — withoutinterrupt, 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_approvalneedshitl:request; one that uses the memory tools needs amemory:block ANDmemory:read/memory:write; one that mountsconnection:-backed MCP servers or runs tools callinggetServiceTokenneedsintegration:executeto 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’scapabilities:could select it, only'*'. Declare the capability it exercises inconstitution/project-capabilities.jsonand list it. A name inprovidesCapabilitiesthat 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 declaringstate: { namespace }must holddb:readanddb:write(or'*') — the state module tells the agent to usedb_query/db_schema/db_execute. Without them the role file does not load, andomnislash 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-ins | Delivered 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, NotebookEdit | file:write |
Bash, TaskStop, Monitor (a shell and its background work) | engineering:execute |
WebFetch, WebSearch | research: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.
| Module | Declared by | What you get | When 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 wire | a top-level memory: block on the role ({} counts — the brain’s MANIFEST.yaml budget), or agent.memory on the heartbeat / hook running it | all 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:delete | none 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 tool | a 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 run | the catalog of the listed skills (on an API model only with skill:read or '*'); the harness Skill tool under promptFlags.skills: harness / both | no 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 instead | schedule:write; agent:execute + runtime:read; harness:subagent, and for the contrast + agent:execute ('*' holds all) | each bullet whose tools the grant can select | the 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 skill | state: (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 module | an interactive turn (the default) | the tool and the question policy | promptFlags.questions: false removes both, on every model; a non-interactive dispatch removes both too |
hitl_request_grant | nothing — 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 person | any 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_connection | integration:execute in capabilities: ('*' holds it) | the agent can request a missing / dead connection itself | a missing connection fails with a message that says to authorize it from the dashboard |
git_request_app | integration: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 installation | no 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 has | that 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 keepsAskUserQuestionandhitl_request_grantand nothing else until a rule names what they may reach —capabilities: [research:read], or['*']to lift the ceiling. Resolved likecommands: 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’sdefault⊕dmceiling. - 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 holdsmemory: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_grantrefuses a capability above the ceiling before any card is raised, namingaccess_widen. Raising the ceiling is anaccess.yamlchange, 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 schema | Owner | Declared in | Migrated from | Journal |
|---|---|---|---|---|
omnislash | engine | @omnislash-ai/infra (shipped) | @omnislash-ai/infra/migrations (shipped) | drizzle.__engine_migrations |
public | brain | {brainRoot}/db/schema.ts (convention) | {brainRoot}/db/migrations/ | drizzle.__brain_migrations |
drizzle | engine | — | — | holds both journals |
Rules:
- The engine line applies first, always. So a brain table may carry a foreign key into
omnislash.*; the reverse is impossible by construction. db/schema.tsis a convention, not a config key. No env var, noomnislash.config.tsfield: the engine probes{brainRoot}/db/schema.tsand, if it exists, adds the brain line. A brain with no such file simply has no brain line. (BRAIN_SCHEMA_PATHno longer exists —BRAIN_ROOTis the only knob, and bothdb/schema.tsanddb/migrations/derive from it.)db/schema.tsexports 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.- Bare
pgTable(), imported from@omnislash-ai/infra/drizzle. NotpgSchema('omnislash')— that is the engine’s namespace, and the brain never writes there (no migration, nodb_execute, nopsql). Notdrizzle-orm/pg-coreeither: 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 opaquetext. 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 istext(auuidforeign key intoomnislash.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). - Migrations are committed SQL, applied on deploy.
omnislash db generate --name <n>→ review the SQL → commitdb/migrations/(the.sqlandmeta/) → thedb-migrateone-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 mostsessions.continueAfterRestarttimes, then it endsinterrupted), and while a turn runs brain-sync holds a push that would deploy, up tobrainSync.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, whichomnislash upgradehands to the agent doing the upgrade. - 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. db/is in the forbidden G-set (§ 2): the migration one-shot reads it from the image and runs beforebrain-sync, which itself waits on that migration — so it can never be hot-synced. Schema changes are a commit + deploy, never a live edit.<namespace>_*inpublicis a reserved name shape. Any view whose name starts with a namespace some role declared in itsstate: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 inpublicnamed<namespace>_*is the namespace’s to create, alter and drop, and that it neverALTERs orDROPs 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_reportisnova’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 isdb:writeathitl: 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:
| Tier | Where you write it | Scope |
|---|---|---|
| engine default | shipped — you write nothing | every brain, until you say otherwise |
| brain-global | omnislash.config.ts (defineProject), constitution/MANIFEST.yaml, constitution/*.json | your whole brain |
| per-entity | frontmatter of *.scenario.md / *.heartbeat.md / *.hook.md / *.agent.md, role files | one 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:
| Value | Brain-global | Per-entity |
|---|---|---|
Which memory lines an agent uses — the one it curates (write), the ones it may deposit into (report) — and how much it carries | constitution/MANIFEST.yaml § memory | top-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 cost | defineProject({ 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.md | constitution/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.md | constitution/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.md | constitution/MANIFEST.yaml § sessions.continueAfterRestart | sessions: { 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.md | defineProject({ 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 § context | top-level context: in a role file, key by key |
| Agent state — the Postgres namespace and how much of it reaches the prompt | constitution/MANIFEST.yaml § state — numbers only, views / rows / chars; a brain-wide namespace is refused, since one prefix for every role is a collision by construction | top-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.md | constitution/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.md | constitution/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 agents | constitution/engine-tools.json | — (§ 7) |
| Capability risk / whether an action needs approval | constitution/engine-capabilities.json | — (§ 7) |
| Context-window notify + auto-compact marks | constitution/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 manifest | verdict / 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 frame | engine 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>.unknownChats | bots.<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 timeout | workflows.execution.defaultTaskTimeoutMs | scenario execution.taskTimeoutMs |
Your brain’s timezone — every schedule: is read in it, and so are “today” and “this week” — default UTC | defineProject({ 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 off | constitution/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 kinds | constitution/MANIFEST.yaml § hooks.gate.timeoutMs / heartbeats.gate.timeoutMs | gate: { 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 once | constitution/MANIFEST.yaml § delegation.parentFirstMs | top-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.5 | constitution/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.6 | constitution/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 § Notices | constitution/MANIFEST.yaml § notify | notify: 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 6 | constitution/MANIFEST.yaml § hooks.chatLaunches.perMinute | chatLaunches: { 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 = forever | constitution/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 anywhere | constitution/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 days | constitution/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 days | constitution/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 days | constitution/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 = forever | constitution/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 age | constitution/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 here | the 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 voice | constitution/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 output | constitution/MANIFEST.yaml § pipelines.outputCorrections, read when the step starts | outputCorrections 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 settle | execution.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 default | constitution/MANIFEST.yaml § pipelines.approvalExpiresInHours, read at each approval | expiresInMs 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 created | constitution/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 retention | defineProject({ 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 destinations | defineProject({ 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 decides | constitution/Agents/system/role-boss.md (replaces the engine’s copy whole) | — boss-mode.md |
| How much reach a boss run opens with | constitution/MANIFEST.yaml § boss: | — boss-mode.md |
| Extra permissions on the deploy’s GitHub App | defineProject({ git: { appPermissions } }) | — widens or raises the engine floor, cannot narrow it |
| Whether that App may be installed on accounts other than its owner account | defineProject({ 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.appPermissionsis merged with the permissions the engine cannot function without (contents: write,pull_requests: write,metadata: read,workflows: write,statuses: write— the loop publishesomnislash/diff-gateon the apply’s pushed head before the PR opens and its pre-merge check asomnislash/validation, the statuses a base-branch rule should require;omnislash doctorchecks that under GitHub merge gate — pluschecks: readandissues: read, which the App’s event subscription costs). AGH_TOKENPAT on the fallback lane needs the same set (minuschecks, which only the App’s webhooks cost). Per key the stronger level wins: add resources freely and raise a floor level ({ issues: 'write' }giveswrite), but{ contents: 'read' }leaves the App onwritewith 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.
| Key | Where you may write it | If you write nothing | How your tier merges |
|---|---|---|---|
evaluation.enabled / loop.enabled | manifest · entity | true inside a block you wrote — but writing no block is off | the 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.threshold | manifest · entity | every graded run is analysed — the absence is the setting, not a gap | your value replaces |
loop.preset | manifest · entity | repair — 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 load | nearest 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 · entity | 2 bad runs out of the last 5 graded | whole 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 · entity | 5 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 pipeline | whole block |
loop.watch { mode, windowRuns, minFailures, deadlineHours, confidence, onInconclusive } | manifest · entity | observe, 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: none | whole block |
loop.autonomy { decide, merge, mergeMethod, maxConsecutiveRegressions, onInconclusive, onUnmeasured, requireCalibration } | manifest · entity | human 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 card | whole block |
loop.budget { maxUsdPerRun } | manifest · entity | unpriced: 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 none | whole block |
loop.evidence { runs, maxTokens, includeTrace } | manifest · entity | the last 5 graded runs with their traces, under 40 000 tokens | whole block |
loop.lessons { maxRecalled, retentionDays } | manifest · entity | 10 lessons recalled, kept 180 days | whole 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.reviewDeadlineHours | manifest · entity | a 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.cooldownHours | manifest · entity | 6h 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 cases | your value replaces |
loop.maxAnalysesPerDay | manifest · entity | 3 proposer agents per rolling 24h; null removes the ceiling and is a real answer, not an omission | your value replaces |
loop.windowStalledAfterHours | manifest · entity | two 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 instead | your value replaces |
loop.proposer.pipeline | manifest · entity | null — 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 revert | your 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.modelRef | manifest · entity | the 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.instructions | manifest · 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.instructions | both tiers kept, the manifest’s first — an entity adds to the brain’s lines, never drops them |
loop.editablePaths | entity only — a manifest that names it fails to load | nothing extra: the accepted proposal’s own files are the whole allow-list | entity-only, so there is nothing to merge |
loop.protectedPaths | manifest only — an entity that names it fails to load | the 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 them | adds 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.maxChangedLines | manifest · entity | 600 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 bound | your 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 one | no 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 confounds | per group id, your declaration whole |
loop.group | manifest · entity | [] — in no group | the 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.maxUngradedShare | manifest only — no per-entity tier, on purpose | a third of a challenger batch may go ungraded before the cycle refuses to read the rest as a verdict | whole block at the tier that writes judge: |
evaluation.method | manifest · entity | default — 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 load | your value replaces |
evaluation.metrics.<id>.aggregate | manifest · entity | median — 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 share | per metric id, per key — the entity’s key over the manifest’s |
evaluation.metrics.<id>.optional | manifest · entity | false — a declared computed metric measure() / window() leaves out fails the grade; true makes the omission no observation. Refused on a judged metric | per 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..1 | identity: a lower tier may not rescale it |
evaluation.human { ask, spotCheck, expiresHours, agreement: { minPairs, tolerance, threshold, maxPairs } } | manifest · entity | ask: 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 pairs | whole 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 judge | per key, nearest tier wins |
chat.episode.idleHours · chat.onRoleChange | manifest 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: block | per key, nearest tier wins |
evaluation.gradeAfterHours | manifest · entity | 0 — 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 overnight | your 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 manifest | the preset loop.preset names — for repair, the engine’s own comparator and its k-of-n trigger count | nearest 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 only | no golden cases | entity-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_promptand 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/safeRunWorkflowin a loop-launched invocation fail withTerminalErrorbefore 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 onctx.loopLaunchedbefore 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’snotify(ctx, …)sends nothing,emitEventemits nothing andkvSet/kvDeletewrite nothing (each step is still journaled).callToolandrunAgentfrom a pipeline carry the mark, so their calls run dry the same way.recordSpendis 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'indefineTool/createTool— the engine then answers for it — or readscontext.auth.loopLaunchedand returns its success without the effect. A tool that declares nothing runs as written. - Your pipelines. Read
ctx.loopLaunched(typed onrestate.Contextby@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 rawfetch. 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.tsas it started (nullif 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 forcallTooland for an agentrunAgentran, 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.idleHoursof silence after the role’s reply (never while the role is still busy on a turn), when the role’s agent callsagent_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 byid), or when your code callscloseChatEpisode(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
roleentity (its runs thechatrun kind) once its*.agent.mddeclaresevaluation:/loop:(a system role never is — move it into a feature). Itsmeasure(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 pastevaluation.context.maxTokens, underconstitution/Loop/chat-privacy.md; the proposer gets each episode’srunId, 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 withoutevaluation:/loop:/chat:, named skills), and the loop never edits thechat:block. It owessample.maxPerDaylike 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 / target | The key is scoped to | Remembered for | A repeat returns |
|---|---|---|---|
scenario_start on a pipeline scenario → a service or object handler | service, object key, handler, idempotencyKey | Restate’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 run | the workflow key (idempotencyKey is ignored: the key already is the id) | Restate’s workflowRetention (1 day by default); after that the key runs again | the same invocation, existing: true |
scenario_start on a workflow scenario | scenario + idempotencyKey | the life of the run row | the 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’sagent_task/agent_call/agent_awaitreturnsawaiting_inputwithpendingHitl: { hitlId, kind, questions, deliverAt }(agent_getundercurrent), and the parent answers it (agent_answer), hands it to a person now with a note (agent_escalate), or letsdeliverAtpass. 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_answerrefuses 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_escalaterideagent:executeand act only on the caller’s own descendants — answering your own child’s question is no more thanagent_enqueuesending 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:
5mwhen 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
deferAbovedeferrable 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_queryfor a role withstate:): 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: 0defers every tool that is not eager.deferAbove: falsenever 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.deferAbovedoes 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’scapabilities: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.
| Scope | Windows | What is summed |
|---|---|---|
brain | day, month | every metered ledger row in the window |
scenario (workflow / pipeline) | run, day, month | run: the rows of one execution, sub-agents included; day / month: every run of the scenario |
role (*.agent.md, system role-*.md) | day, month | every 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 MANIFESTbudget.roledefault. A budget at or over its effective limit (configured + raises granted for that window) refuses the turn before any model call: terminalfailed,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 byresult.errorCategory === BUDGET_REFUSED, never by the message. It is distinct frombudget_exceeded, a CLI turn that ran past its owncli.maxBudgetUsd(and so did spend). - What the refusal does to each consumer. A workflow run parks in
awaiting_retrywith its completed tasks kept and re-drives later or after a raise;safeRunAgentreturns the failed result to your pipeline code (runAgentthrows it as aTerminalError); 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 HITLapproval, producerbudget, to the default HITL chat (TELEGRAM_HITL_CHAT_ID, else the primary owner’s DM;payload.dataabudget-incident.v1record). Every refusal while it is open joins it; aday/monthcard 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:writeor 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 thescenario/roledefault when noidis given) or on one scenario’s / role’sbudget:— 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_updaterefuses thebudgetsection, and every other write of a role, scenario or heartbeat file refuses a change to itsbudget: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’sevaluation:likewise, which isloop_set’s) — so none is a way aroundbudget: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.
whenis a closed vocabulary —run(the turn is a run of the feature’s own heartbeat, hook, scenario task or pipelinerunAgent),chat,reply,ask(a catalog line the agent opens withcontext_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’scontexts: { <context>: <entity> }inconstitution/access.yaml, orcontext_bind(capabilitycontext:bind; the pipeline SDK’sbindContext). - A role receives only what its
contexts:lists (['*'], names, or nothing = none — fail-closed, likeskills:).runand anaccess.yamlbinding bypass the list: the feature tied itself to the role when its entity named the role inagent:, and the group rule tied the chat to the context.reply,ask, code and acontext_bindbinding are inferred and do not. - Brain code may link a turn (
code).<id>.context.tsbeside<id>.context.md(defineContext({ match, render })),attach: [{ context, entity? }]in theengage()verdict, and{ entity }/{ reach }in a hook gate’s verdict (carried by the fire’srunlink — as arerunAgent({ entity })/({ reach })in a pipeline step, which need the invocation’s scenario to belong to a feature). Inferred — the allowlist, the pin andkeepapply; it needs nowhenword. It fails closed: code that does not load, throws, outlivescontexts.codeTimeoutMsor answers the wrong shape links and renders nothing, with the reason in the log (contexts.md § 2.3). DoctorFeature context codereports a.context.tsthat does not load or has no context. - A bound chat pins its context — tenant isolation. In a chat bound to
C@e,Cattaches with keyeonly, from any link: another key is refused (context_opensays why), a link without a key takese. Anaccess.yamlbinding 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. Acontext_bindmay not contradict the chat’saccess.yamlbinding. - Attached context rides the turn tail, never the system prompt. The system prompt carries only the catalog of
askcontexts 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. DoctorFeature contextsreports what loads but cannot work: a context no role admits and norunreaches,askwithout adescription, a view two contexts claim, a view orbycolumn that does not exist, a role’scontexts:naming no context, a role admitting anaskcontext withoutcontext:read, abyview on a context whose links carry no entity key (always, orrunin 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’snotify()) 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 wasabout) — 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 whosewhenholdsreply, each with its key — the reply sees what the run saw — and the link holds for the context’skeepturns 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 whosereplyreach entries all declare anentity:and carry no key must say which one in its send — a send with noaboutis refused, naming each context and its key — so a reply never comes back unkeyed (contexts.md § 2.1). A send tool refuses anaboutnaming a context that does not exist; a pipeline’snotify()/ 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_contextreports per role how often each context attached and was stubbed, its mean size and its links (a stubbed one raises thecontext-stubbedfinding);/contextin a chat names what the latest turn carried (contexts.md § 5.1). - A context is text, not a right. It grants no tool;
context_openitself is capabilitycontext: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 ininputorstate. - The spec is checked before a widget opens, never defaulted. Unknown keys are refused; a
toolsentry 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 refuseswidget_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_ingestanddefineEventSourcerefuse it), eventwidget:submitted, resourcewidget:<id>plus anyresourcesyou passed. Its sender is the person the engine verified, on the event’sactor; what the page itself sends is fenced as untrusted text — in a turn and inwidget_getalike. One page key from one person = one event. - Destinations are closed.
widget_createposts 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 untilwidget_widennames 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 iswidget_widen, a person’s decision. - A turn only where the answer lands. With
deliver: turnthe page’s submits come back to the agent that opened the widget as ONE turn per burst (debounceMs, at mostmaxWaitMs), 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) deliversevents— unless it asks fordeliver: 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_createsays 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 tomaxTurnContextBytes): 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 adeliver: chatwidget) comes fenced, its author named. The state itself never rides a turn: it is the page’s data and may carry what people typed, sowidget_getanswers 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’scsp.connectDomainsand nothing else; images, media, fonts and scripts load inline, fromdata:/blob:, fromcsp.resourceDomainsor from the media you granted — inwidget_create’smedia, or later inwidget_update’s (URLs that work only while the widget is open) — or made for it:voice_render({ widget })/image_generate({ widget })and your code’smediabytes become the widget’s own file, at mostmaxMediaBytes, 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? })andpage(the widget whose page made this call, elsenull— gate a page-only tool on it, never oncontext.auth.widget). With yourtxthe push commits or rolls back with your writes and holds the widget’s lock until you commit (push last);stateis 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 holdswidget_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-rununder a loop replay,refusedwith a code and reason) and throws only for a malformed call; passingdbinstead oftxcompiles 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: truequotes 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’schat): a widget with no chat, a business chat, policytranscript: off. A page message shows in the chat as the person’s quote, above its answer (transcript: offkeeps 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.uifrom 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 itstoolslist. In Telegram the page signs in with Telegram’s launch data, checked against the bot that posted the widget and no older thanauthMaxAgeSeconds; only someone in the widget’s audience (the person it was opened for — in a business chat the counterpart —, the owners, whoeverwidget_widenadded) 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, themembertier 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’stools, 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 mosttoolTimeoutSeconds, and a failed one shows the person only the tool’s own classified refusal (refusal(message)), else an engine sentence — never a plainfailurestring. Submits, messages and non-read tool calls count againstsubmitsPerMinuteper 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:createin itscapabilities:;widget:widenasks 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,cspdomains,policy) also needsfeature: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; doctorWidget launch) — and only on a deployment with https origins (doctorWidget hosting).menu: truemakes 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’sui://engine/formunless 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’sdatais held to itsresponseSchema(the review decision’s by default forui://engine/review, and required on the page’s own action), an input to itsinputSchema— and the widget closes when the card is decided anywhere; it has no expiry of its own, andwidget_close/widget_widenrefuse it. An agent’s card keeps that agent’s ceiling over the page’s tool calls. A card’s page is aui://widget file checked at request time (it must open, itsinputmust fitinputSchemaandmaxInputBytes; itspolicy: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 — doctorWidget browser sign-innames 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’sbrowser:(audience·owners·off; a business chatoffunless a rule opens it; opening it isaccess_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 thanauthMaxAgeSeconds— at sign-in and at every renewal and call of an open page.widget_list_sessionslists them,widget_revoke_sessions(access:write) ends one or a person’s all, and blocking a person inaccess.yamlends 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_previewrenders 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 toconnectDomainsonly mocks —resourceDomainsalone are fetched, by the engine. A publication (artifact:) or media needsruntime:read. Without the deployment’sbrowserservice it answerspreview unavailable: …, never a failure — widgets.md § 16. - Lifecycle is explicit.
openuntil 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) — thenclosedfor good, with the reason on the row; pastexpiresAtit takes no submit or push even before the close lands;retentionDaysafter closing it is deleted with its pushes. A retriedwidget_createwith the sameidempotencyKey(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.schemais a check on the file’s content, not a generation constraint. Forformat: 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 — theirschemais a prose outline. See./deliverables.md. storagedoes not change this.inline/blob/nonepicks where the file is persisted, not whether its declared structure is a contract — the check runs the same for all three.workflow_task_runs.resultis the agent’s prosesummary— the human-readable record of the turn, never your deliverable. Same for anything of yours reading an agent’s structured output directly (a hook onagent:completed, a tool that inspects a finished task): the envelope there carries no payload of yours either. Read the deliverable from the run’sdeliverablecolumn, 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: truetask 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.failedevent, 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.