Feature Inventory
Exhaustive by design — never trim it
This is the engine’s capability index. It is a contract for consuming repos: brain authors and anyone integrating with the engine read this to learn what already exists, and they read it without access to this source tree. Nothing here is “derivable from the code” for them — they have no code to derive it from.
Exhaustiveness is the feature. Length is justified by coverage, not by how many decisions a section carries, and this file is read on demand, never loaded into every session — its size costs nothing until someone needs it. Never trim it for context-budget or doc-rot reasons: a missing entry is a lost capability, and the default outcome is that someone rebuilds what the engine already ships.
It lives in
docs/brain-dev/because that folder is the corpusomnislash upgradecopies verbatim into every brain’sdocs/engine/. Words written here arrive in consumer repos at the engine version they run, so they may not point outside this folder: the sibling-only link gate inpackages/cli/src/docker/engine-docs-builder.test.tsfails CI on any link that would 404 once flattened into a brain.
The exhaustive list of shipped engine features. Three levels: area (##) →
feature → sub-feature. What a brain is and why it is shaped this way:
PHILOSOPHY.md. What the engine guarantees a brain and what it
demands back: contract.md.
A line here answers one question: does the engine already do this? Its job is findability — name the mechanism, name the symbols and defaults worth grepping, and let the reader leave for the code. So: one sentence, ~30 words, 45 at the outside (a bare list of names is measured in items, not prose). Present tense. No rationale for why it was built that way, no account of what it used to do wrong, and nothing carried across from the doc-comment beside the code — that reasoning is already where a reader who wants it will look. A line that needs more than this is a sub-feature that has not been split yet.
Contents
Framework — Engine-as-a-Framework
Agents — Agents Runtime · Models & Model Routing · Agent Isolation & Execution Context · Chat & Conversational Surface
Governance — Tools & MCP · HITL (Human-in-the-Loop) · Policy & Security
Orchestration — Cascade Planner · HTN Planner · Workflow System · Durable Pipelines (Restate) · Artifacts & Deliverables · Evaluation & Self-Evolving
Automation — Scheduler & Heartbeats · Hooks
Knowledge — Memory System · Skills System
Surfaces — Control Plane (Dashboard) · Events System · Media Processing · Integrations
Platform — CLI · Distribution & Deployment · Data & Persistence · Infrastructure
Operations — Observability & Operations · CI/CD
Engine-as-a-Framework
- Brain ↔ Engine Separation — engine is runtime + infrastructure, brain is domain + behavior; one engine serves many standalone brain projects
- Ownership split — engine owns agent execution, workflow engine, queues, bot framework, system table definitions, CLI, Docker and monitoring; brain owns prompts (
constitution/), capabilities/grants, planner methods, custom tools, its own tables and the PostgreSQL instance - No domain code in the engine — engine carries no brain-specific prompts or capabilities, and a brain can be open-sourced without exposing engine source (engine ships as npm packages + a GHCR base image)
- No engine internals in the brain — brains import only from
@omnislash-ai/*public packages; extension happens through declared points, never monkey-patching or subclassing - Criteria checklist — the full EaaF criteria (separation, config-as-code, runtime composition, schema composition, extension points, operational guarantees, distribution, CLI, zero-config dev infra, production build, DX) with per-item status live in
docs/eaaf-criteria.md
- Ownership split — engine owns agent execution, workflow engine, queues, bot framework, system table definitions, CLI, Docker and monitoring; brain owns prompts (
omnislash.config.ts+defineProject()— one typed TypeScript file is the entire engine↔brain contract, validated before the runtime starts- Runtime composition, zero codegen —
loadProjectConfig()reads the file once at boot (walk-up in file mode, image-baked in Docker); apps are factory functions (createBot,createWorker, …) returning{ start, stop, status }; there is no generated directory and no engine-specific compile step - Config vs env boundary —
ProjectConfigis public, committed configuration; secrets/private/infrastructure values live in.envand are read viaenv.*directly in packages. The config file must never importenv - Brain-declared env schema —
defineProject({ env: z.object(...) })merges the brain’s Zod shape into the engine’s at load time, so validation,getEnvCatalog()andcheckProdEnvRequirements()all see the combined shape;.meta({ category })groups brain vars in the dashboard catalog - Function-carried fields —
tools,ingest.sources,bots[].policy,kpis[].readcarry brain code by reference (file mode only; the function itself is never validated —kpisis the one whose declaration around it is) and may readenvinside their bodies even though the config module may not - Fixed dirs are convention, not config —
features/,constitution/,skills/,memory/,constitution/models.jsonderive from the brain root;defineProjecttakes nopathskey (passing one throws with a pointer tofeatures/<feature>/)
- Runtime composition, zero codegen —
- Display formatting for people —
formatInstant(Oct 12, 20:17 GMT+2, the brain’slocale.timezone),formatDecimal(2.33,0.004,12,400) andformatUsd($7.50,$0.0042below a cent) from@omnislash-ai/config/public: the one spelling the engine uses in every chat notice, card and reason, so a brain’s ownreasontext reads the same- Per-service toggles —
services: { bot, worker, scheduler, mcp, webhooks, monitoring, pipelines, dashboard, caddy, nocodb, n8n, playwright, browser, brain-sync }, all defaulttrue; a disabled service drops its compose template, itsdepends_onreferences, its Prometheus scrape job and its domain-mode Caddy vhost - Fail-fast validation —
projectConfigSchema.safeParse()rejects a bad config before the runtime boots, with a stableOmnislashErrorcode + actionable hint instead of a stack trace - Loading is parse-only; the runtime invariants are the app’s —
loadProjectConfig()parses the file and installs the env shapes it derives, and never reads through the validatedenvproxy, so it succeeds on a brain whose environment is half-filled or absent. The cross-layer config+env rules (isolation ⇒ BRAIN_GIT_REMOTE, Telegram surface ⇒ an addressable HITL chat) live invalidateRuntime(config), which the shared app-boot ritualbootstrapEngineAppruns immediately after the load. That split is what letsomnislash doctorDIAGNOSE a broken environment instead of dying of it, and letsregen/upgraderegenerate artifacts in a CI job that carries no brain secrets at all
- Per-service toggles —
- G / R / W Path Classification — every path under
/app/brainis exactly one of git-backed-and-writable (G), image-baked-read-only (R), or host-bound runtime output (W)- G-set composition — engine baseline (
memory/,constitution/,skills/,features/) ∪brainSync.additional;resolveGPaths(config)is the single source consumed by sparse-checkout anchors, the daemon commit set, the compose subpath mounts and.dockerignore - Runtime-only directories —
brainSync.runtimeOnlynames the G-directories whose whole tree the stack reads on demand (never imported or parsed at boot): a push touching only those needs no deploy. Engine tier is every.mdplusmemory/;resolveRuntimeOnlyDirs(config)is the one list the generateddeploy.ymlpaths-ignoreand brain-sync’s deploy-drift clock (BRAIN_SYNC_RUNTIME_ONLY_DIRS) are rendered from (ci.ymlhas no push trigger, so no filter).defineProject()rejects a directory that is not a G-path (image-baked, so only a deploy carries it) or that the engine loads at boot (features/,constitution/,skills/). TheWorkflow filtersdoctor check catches an owned (.override)deploy.ymlwhose hand-edited filter drifted from the list;Owned workflowswarns when such a file is frozen on an older engine template than the one installed - Additive only — a brain can extend the G-set but never shrink the engine baseline (a v0.131 narrowing knob silently lost dashboard writes to
skills/and was removed) - Forbidden G-paths —
ENGINE_FORBIDDEN_G_PATHSrejects structural entries (package.json, lockfiles,.npmrc,db/,omnislash.config.ts,env.schema.ts,tools/,node_modules/,.git/,.env) and blast-radius entries (.github/,docker/,Dockerfile*,.dockerignore) - Directories only — the sparse-checkout anchor is
/{path}/, which git never matches against a blob, so a file or symlink-to-dir entry would never materialize;assertGPathValidthrows atdefineProject()and theBrain G-pathsdoctor check fails - Materialization guarantee —
brain-syncmkdir -ps every declared G-path after checkout, because docker refuses to start any container whose bind source is missing — one unmaterialized path would take every app service down at once - Criterion for adding a path — G is the agent’s unreviewed push surface; a live-mode agent’s write outside the G-set succeeds and then vanishes on container recreate
- Image-baked deps asymmetry — new files inside G are hot; new external deps are cold — a new npm import in a
*.pipeline.tsor a newinstall[]spec in aSKILL.mdneedsomnislash build
- G-set composition — engine baseline (
- Brain Runtime Persistence (
brain-syncdaemon) — a dedicated long-running service is the single owner of brain git state, keeping the G-set synchronized between the brain repo and thebrain_statevolume- Bootstrap —
git sparse-checkout set --no-conewith anchored G-path patterns, shallow fetch,checkout -B <branch> origin/<branch>; a poisoned or corrupt.gitis rebuilt in place — whatever the worktree holds beyondoriginis pushed to abrain-sync/recovered-<time>branch to merge, and the worktree is reset toorigin; on an existing volume the checkout’soriginis reconciled againstBRAIN_GIT_REMOTEfirst, so a transport switch reaches the persistent tree on the redeploy that made it (a different repository is reported, never re-pointed) - History window — after bootstrap
keepRecentHistorydeepens the shallowbrain_statecheckout to the lastHISTORY_WINDOW_DAYS(30) viadeepenHistorySince(fetch --shallow-since), never shortening it, best-effort - Steady-state loop — every
BRAIN_SYNC_INTERVAL_MS(default 5 min): repair the sparse worktree →fetch→ commit the G-paths →rebase origin/<branch>→push(no pre-pull; onepull --rebase+ retry only on a non-fast-forward rejection) →fetchBaseline()for the per-run sandbox alternates source; fetch-before-commit keeps the network round-trip out of the gap between the snapshot and git’s clean-worktree check, commit-before-rebase is deliberate - Self-heal and escalation — each tick starts with a
sparse-checkout reapply(a no-op on a healthy tree) because a worktree that loses its skip-worktree bits fails every later tick withcannot pull with rebaseand cannot recover on its own; and a streak of ticks failing with the same non-conflict error sends one 🆘 to the status chat, with a ✅ stand-down when syncing resumes - Boot gate — every brain-consuming service declares
depends_on: { brain-sync: service_healthy }, so nothing starts before the G-set is materialized; the gate covers that and nothing else (the agent-sandbox baseline and attached-repo clones follow it, and an agent dispatched before the baseline lands waits for it — up toBRAIN_BASELINE_WAIT_TIMEOUT_MS, then a retryable failure — rather than running unsandboxed). The healthcheck stays green while the last tick is within 3× the interval - Manual sync — a
brain-sync:command/brain-sync:completedpub/sub pair driven by thebrain.syncop — itsbrain_syncagent tool and the dashboard “Sync now” button (outcome,durationMs,conflicts;skippedwhen no daemon runs); an in-flight tick is waited out first, conflict holds apply - Tick deadline — each tick runs under
BRAIN_SYNC_TICK_TIMEOUT_MS(default 2 min) composed with the shutdown signal, so a credential mint or a git op against a stuck remote is aborted and retried on the next interval instead of holding the loop’s single-tick slot forever. Bootstrap’s cold clone is deliberately outside that budget — the healthcheckstart_periodbounds it - Recovery posture — tracked dirty state is committed (never stashed), non-conflict pull failures warn and retry next tick, untracked files are swept into the next commit, and SIGTERM drains the in-flight tick through an
AbortSignalso the volume is never left mid-rebase - Observability —
brain_sync_last_tick_seconds,brain_sync_interval_seconds,brain_sync_conflicts_total{direction}plusapp_check_healthy{check="brain-sync-bootstrap"|"brain-sync-staleness"}, surfaced throughhealth.overview’sbrainSync(the dashboard’s sync badge, an agent’shealth_overview) - Git identity —
GIT_AUTHOR_NAME/GIT_AUTHOR_EMAILare optional once an App is registered (until then they are required — brain-sync fails fast at boot with no identity from either tier): unset, they are derived from the registered App row (<slug>[bot]/<app_id>+<slug>[bot]@users.noreply.github.com) byapplyGitIdentityDefault, so a commit is attributed to the bot rather than to whatever ambient identity the container carried. Set, the env pair wins. Either way the value is mirrored intoGIT_COMMITTER_*so every git invocation, including bwrap’d agents, has a complete identity
- Bootstrap —
- AI Conflict Resolution (supervised episode) — a rebase conflict exports the daemon’s unpushed commits to origin and spawns a
system:git-conflict-resolveragent in a clone of its own; the episode is closed only by a git-verified, clean tick- Export first — the rebase is aborted (local commits preserved) and the local tip is force-pushed to the engine-reserved branch
brain-sync/conflict; from that moment the commits are durable on origin, and a human can resolve by hand - Open once — one ⚠️ names the branch on the status chat, and a single-instance resolver is dispatched (
Agents.recreate({ ifBusy: 'throw' }),runIn: 'clone',interactive: false,ResolverReportSchemastructured output) with the exact range to replay:base..brain-sync/conflictontoorigin/<branch> - Hold — while the resolver is busy the sync loop performs no git operations, so it never re-exports a moving tip under the agent; a stalled attempt past
RESOLVER_DEADLINE_MS(15 min) is stopped and respawned against the same export - Verify, adopt, close — the resolver’s reported
pushedTipmust be a push made after the export (a strict descendant of theorigin/<branch>the resolver was handed) that sits on the freshly fetchedorigin/<branch>, or the attempt fails and re-dispatches with the tree untouched; on success the daemon replays only what its writers produced during the hold onto the resolved branch, pushes, deletes the side branch and sends the ✅ report (duration, attempts, the agent’s own summary); repeated failure escalates 🆘 at attempt 3 and every 10 thereafter - Restart-safe — episodes are in-memory but the side branch is the record: a restarted daemon re-reads the export from origin on its first tick and resumes supervision — holding for a still-running resolver, verifying and adopting one that finished during the downtime — without a duplicate ⚠️; a side branch already integrated by the previous process is retired
- Private workspace — only
brain-syncmounts the sync clone’s.git; the resolver and every other agent see G-paths through subpath mounts and reach the daemon’s history only through origin
- Export first — the rebase is aborted (local commits preserved) and the local tip is force-pushed to the engine-reserved branch
- Content Hot-Reloading (
ContentWatcher) — a genericnode:fs.watchwrapper with per-directory debounce reloads brain content without restarting any process- Zero domain knowledge — dir → callbacks, 300 ms debounce per directory, observer-style unsubscribe, error isolation per listener, missing dirs warned and skipped
- Per-app wiring —
workerreloads capabilities / tool policy / planner methods / loop prompts (+ skills) and republishes the tools a widget page’s call runs on,mcpadditionally republishes the tool catalog and fires MCPnotifyListChanged,chat-corereloads capabilities + tool policy + skills,pipelinesadds a filtered*.pipeline.tslistener that rebuilds and re-registers Restate services,webhooksreloads capabilities + tool policy for the tool door it hosts,dashboardreloads capabilities + skills (it holds no tool registry, and its tool lists come from the Redis catalog mirror),schedulerpokes its reconcilers - Runs in production too — the caches it resets are boot-time caches with no self-revalidation, and the G-set is hot-synced by
brain-sync; entity loaders are deliberately absent because they self-correct on a content fingerprint - Reads held until a change — every process trusts its watch: an entity loader’s probe and the features listing answer from what the process last read until the watch reports a change under
features/,constitution/or the engine’s templates, at which moment (ahead of the debounce) the next read walks again; a read outside the watched directories, or under a watcher that failed, walks every turn as before - Keep-last-known-good — reloads are eager with fallback: a broken
engine-tools.jsonkeeps the previous valid policy and logsSTALErather than stripping the agent of every tool; hard fail-fast stays at boot only
- Brain Runtime Contract (singleton shape) — the five things a brain owes the engine, enforced at install, at build and at boot
- Single-pin peer shape —
@omnislash-ai/*are declared exactly once, inpeerDependencies, at an exactX.Y.Z; any entry independencies/devDependenciesproduces a second physical copy and bifurcates module-level state - Committed
.npmrc—auto-install-peers=true+strict-peer-dependencies=true(install-time guard against singleton skew) plus a scope-routing line derived from the engine’s ownNPM_REGISTRY(empty under public npm). Never an auth line: a committed_authToken=${NODE_AUTH_TOKEN}outranks~/.npmrcand pnpm expands the unset variable to an EMPTY token it then uses, so the placeholder shadows the real credential — auth belongs to the environment (~/.npmrc,actions/setup-nodein CI,registryAuthEnv()for children the CLI spawns) - Three enforcement layers — strict-peer at install (aborts the image build on a version mismatch),
omnislash doctor’s structuralBrain package.jsoncheck, and theassertEngineSingletons()boot probe inbootstrapEngineApp()(production only) that fails if any@omnislash-ai/*resolves back inside the brain root - Lockfile decoupling — the brain commits
pnpm-lock.yamlfor local installs only; the image build rides engine’s lockfile and fresh-resolves brain-only deps, so no workspace-shaped importer paths are required of external authors .gitignorediscipline for CLI agent dirs — once.claude//.codex/are in the G-set their runtime subpaths (projects/,todos/,sessions/,settings.local.json, …) must be excluded or the daemon commits CLI churn every tick; a warn-level doctor check arms on the declaration
- Single-pin peer shape —
- Suffix-Based Content Layout — brain entities are discovered by filename suffix inside feature directories (
features/<feature>/, any depth), not by a registry- Entity suffixes —
*.scenario.md,*.heartbeat.md,*.hook.md,*.agent.md, discovered depth-agnostically inside a feature; system roles (constitution/Agents/system/role-*.md, a namespaced id one subdirectory per:—system:loop:proposer↔loop/role-proposer.md) are fixed-path engine convention, not features - Flat feature directories —
features/<name>/holds the scenario, its pipelines, helpers and fixtures as siblings, with no spec/code split; adding a feature is amkdir+ commit, no image rebuild unless a new npm dep appears constitution/is configuration only —MANIFEST.yaml,models.json, the capability/tool JSONs,PHILOSOPHY.mdand system prompts; entities there are not loaded- Misplaced files fail boot —
defineProjectrefuses to start when any entity or feature file (.scenario.md .heartbeat.md .hook.md .agent.md .pipeline.ts .blueprint.yml .loop.ts) sits underconstitution/or loose infeatures/, listing every offending path; theBrain entitiesdoctor check reports late arrivals as[misplaced] - Per-feature pipelines — a scenario owns its
*.pipeline.tsfiles by NAME, anywhere inside its feature (subfolders, a directory shared with other scenarios): one primary (<scenarioId>.pipeline.ts) plus 0..N auxiliaries (<scenarioId>-<suffix>.pipeline.ts), when several ids match, the exact name wins, then a scenario with its own primary, then the file’s directory, then the longest id; filename must equal<name>.pipeline.ts, names globally unique; a file no pipeline scenario of its feature owns is logged at boot and failed by doctorBrain entities, never silently dropped - System files at fixed paths — system prompts (
constitution/Agents/system/*,Grants/custom-grant-parser.md, planner stage prompts,Evaluation/Methods/default.md,Loop/*) ship as engine defaults in@omnislash-ai/config/templates/and are overridden file-by-file by placing the same path in the brain’sconstitution/ - Brain-tunable probe TTL — every entity loader re-walks
features/to decide whether its cache still describes the disk;defineProject({ contentProbe })caps how often each kind pays for that walk (engine defaults:hook1000ms, every other kind 0 = walk per read), so a brain with hundreds of scenarios collapses the per-scenario engine classification bursts onto a single walk instead of paying one each - Env var substitution — brain content interpolates env values at render time, with documented best practices in content-layout.md
- Entity repair — a
*.scenario.md/*.heartbeat.md/*.hook.md/*.agent.mdwhose frontmatter stops parsing is repaired in place byEntityRepaireron boot and on every content tick, announced to the status channel so the diff can be reverted
- Entity suffixes —
- Entity vocabulary — a feature ships four entity kinds,
scenario·heartbeat·hook·role(a*.agent.md), and every engine surface names one askind:id— split on the FIRST:, sorole:system:loop:proposeris the rolesystem:loop:proposer(@omnislash-ai/types/entity:ENTITY_KINDS,EntityRef,formatEntityRef/parseEntityRef,LOOP_TARGET_KINDS,ActionTargetfor what a command acts on); how one is doing is the health setoff·ok·attention·failingplus how many of its executions are in flight (@omnislash-ai/types/health:HealthState,EntityHealth) — the Brain Atlas speaks it already - Feature Switches — a feature is a top-level directory under
features/; everything inside it ships and switches togetherFEATURE.md— optionalfeatures/<id>/FEATURE.mdwith strict frontmattername?,description?,icon?(a Lucide icon name as lucide.dev writes it —chart-line,users— from the dashboard’s curated set, whichfeature_set_iconlists in itsvalueenum; the dashboard shipslucide-react1.47; never an emoji; no icon draws none),enabled?(defaulttrue; an unknown key such asenable:is an error) and the feature’s README as body; absent = the feature is on- Feature tools —
feature_list/feature_get(feature:read, since 1.23.0 the brain index: every feature with the entities it ships — their own and effective flags, whether each declares anevaluation:block (evaluated) and the files each owns; a scenario’s inline hooks included, each ahookof the scenario’s feature with no files of its own (their definition is the scenario’s file) — its own code modules labelled by what they declare registering (inboundevent source,serviceOAuth provider or integration,bot-policy,tool,lifecycle, elsemodule), files that do not load, entities in other features that use its roles, theFEATURE.mdsha; no run state — how they run ishealth_list),feature_trend(feature:read, since 1.24.0: one feature’s runs, failed runs and its agents’ chat turns per hour or day over24h,7d,30dor any window — the chart the Features page opens from a feature’s week),feature_read_file(feature:read, since 1.23.0: theFEATURE.mdas written with its sha — empty when the feature has none yet),feature_set_enabled/feature_set_iconand, since 1.23.0,feature_write_file(the wholeFEATURE.md, raw) (feature:write): dry-run → confirm → compare-and-swap on that sha, answering{ sha, diff, consequences: { liveRunsKilled } }— the dry run of a switch-off says how many live pipeline runs it would kill; a staleexpectedShais refused asconflict, a result that would not load asinvalid, and the file keeps every comment. Since 1.25.0 a feature also lists its widget files (widgets: each page’sui://address, sha and the.widget.ymlbeside it), andfeature_read_file/feature_write_filetake a widget file’spathto read or write one (widgets.md). The same ops back the dashboard’s Features page feature_summary(feature:read, since 1.24.0) — how big every feature is and what it did over a window (window:24h,7d— the default —,30d, …, orfrom/to;featurenarrows it to one,enginethe built-in one), per feature and per entity it ships: what it ships by kind (ships); itsweight— its share of the brain, 0–1, by what it is made of alone: its units over the brain’s — a scenario (a pipeline among them) 3, a hook 2 (a scenario’s inline hooks included), a heartbeat 1, an agent 1, a code module that registers something 1 (a helper none), a file that does not load as its kind (a context file as code); activity and spend do not weigh; the built-in feature is not weighed —; runs started and how many succeeded, failed or are still in flight; its agents’ own sessions — chats outside any run — with their turns; sessions busy now (working, a session awaiting input included); what executes now (running: runs running, sessions in a turn — never one queued, paused or waiting on a person); requests about it waiting on a person now (needsYou, attributed ashitl_summaryattributes them — what the Inbox counts); and what its runs and sessions cost. An entity that did nothing is left out of its feature’sentities;totalsis the whole brain, summed from the records — ad-hoc and system runs, runs of an entity no feature ships any more and every pending request included. The size and figures the dashboard’s Features page draws its tiles from. How each is doing:health_list; each run on its lane:run_timeline- The Engine feature (since 1.23.0) —
feature_listalso answers a built-in feature, idengine(builtIn: true): the engine’ssystem:roles as its entities, the system role files that do not load as its broken files; it has no folder, noFEATURE.mdand no switch (feature_set_*/feature_write_filerefuse it). Every attribution reads it the same way — asystem:role’s agents, runs, spend and requests belong to featureengine. Its shared instruction modules areinstruction_list/instruction_get(feature:read) andinstruction_write_file/instruction_reset(role:write: the first save copies the engine template into yourconstitution/, a reset deletes the copy) — the self-evolving loop’s prompts among them since 1.24.0 (loop/…ids forconstitution/Loop/**, each with the loop agents that read it and when, and a save refused when a placeholder would reach the agent unfilled); its passes areschedule_overview’s - Reserved feature id (since 1.23.0) —
engineis reserved: afeatures/engine/folder is not a feature — each entity file in it is skipped naming the folder,defineProjectrefuses to boot on them, and doctorBrain entitiesnames the folder ([Reserved feature id]). Rename the folder - Effective
enabled— each entity runs only when its own frontmatterenabled(defaulttruefor every kind) AND its feature’senabledare true; entities carryfeature,declaredEnabled(the file’s own flag — what entity toggles write) andenabled(effective — what scheduler crons, hook dispatch, pipeline registration, the heartbeat worker, doctor and the loop tick act on) - Non-destructive — switching a feature off writes only
FEATURE.md; switching it back restores each entity’s own flag - Roles switch too — a
*.agent.mdin a switched-off feature resolves like a missing role; theAgent rolesdoctor check flags enabled entities that dispatch to it - Hot —
FEATURE.mdis part of the entity-loader fingerprint, so a flip self-corrects caches like an entity edit; the pipelines service re-registers when the set of enabled pipeline scenarios changes - Broken
FEATURE.mdholds, not drops — an unparsable file makes every entity in the feature anunparsableskip pointing at it: crons are held and entity repair tries that file once
- Entity & feature health — one health set for every entity and feature,
off · ok · attention · failingplus how many runs are in flight (a count, not a state), judged by the engine from its own records (since 1.21.0)health_entity/health_list(runtime:read, thehealth.entity/health.listops; since 1.23.0 onehealth_listcall answers every feature, or one withfeature, and each entity carries its trigger in words with its next fire and its ten newest runs asoutcomes, newest first, eachlatewhen a clock-started run started past its grace, and how many tool calls failed on its newest run (lastRunToolErrors);feature_listcarries no run state since) — first matching rule wins: Off (switched off, its feature off, the scheduler stopped or adisabledschedule — the last two only for an entity on a clock); Failing (its file or plan file broken, not served by the pipelines build or no build served, a run that gave up after its retries or broke on an edit — reset it, a schedule that never fires (outside the scheduler grammar, or a cron with no valid time like0 9 31 2 *), a hook gate that threw,failingAfterfailed runs in a row, Down:downAfterexpected fires in a row that never started); Attention (runs, requests with no run behind them (an input a start waits on, a scenario grant request) or evolution changes waiting on you, Late: a scheduled start past its grace, or one expected fire missing, a failed run short offailingAfter, tool failures on the last run, a run queued / running / retrying paststuckAfterMs, a role or pinned model on an open provider circuit, a schedule declared but not registered, a workflow scenario with no plan yet, an edit the pipelines build has not taken — the previous build still serves, a*.pipeline.tsthat never runs); OK. Every reason in words, most severe first; since 1.24.0 an entity whose model chain cannot dispatch on its first step for a CLI sign-in or provider key the credential inventory grades missing, expired or rejected (“a fallback carries it” when a later step still can; a fallback step alone lacking one is no fault), or whose role’s MCP server authenticates through a connection that is expired or rejected (mcpServers.<name>.connection), is Attention too, and every row carriesblockedBy— theprovider:of each open circuit on its model chain and thecredential:of each such sign-in, key or connection (an MCP server with no live connection blocks too — its revoked connection named, none at all said in words), what the entity page offers as Open provider / Reconnect. A feature is the worst of its switched-on entities plus its own faults (aFEATURE.mdthat does not load, a stray pipeline file), always with the counts in words (1 failing · 2 need attention · 4 OK). Infrastructure health ishealth_overview- Summaries (since 1.23.0) —
run_summaryaddsonTime(the share of clock-started runs that started within the grace Late reads by — no new number),evalPassRate(graded runs whose grade passed — since 1.24.0 withevals, the window’s graded and passed counts it is a share of),errorClasses(failures perexecutions.error_class,unclassifiedfor none) and, withby: "feature" | "entity",groupswith the same figures and each group’s newest failure;hitl_listtakesfeature/entity(a request’s run’s entity, the entity of the tick or fire whose turn — or a sub-agent of it — raised it, the scenario it names, else the role of the agent that opened it — derived at read; ascope: chathook’s agent outlives its fires, so its requests are never the hook’s);hitl_summary(hitl:read) answers what waits on a person per kind and per feature and the requests opened per hour or day;usage_breakdown/usage_trendtake thefeaturedim (the feature shipping the row’s entity now, else its role’s; asystem:role or one no feature ships isengine), grouped before the top-N fold - The windows are yours —
constitution/MANIFEST.yaml§healthfor the brain,health:in a scenario’s, heartbeat’s or hook’s frontmatter for one entity, each key nearest-tier-wins:graceRatio/graceMinMs/graceMaxMs(grace =clamp(ratio × period, min, max), default 10% within 5 min – 1 h; set one bound alone — a min above the max wins),downAfter(default 2 fires),failingAfter(default 3 runs;1= every failed run is Failing),stuckAfterMs(default 2 h). Down counts fires, not hours, so a weekday cron is not Down over a weekend, and a manual run does not stand in for a missed fire; it counts only the fires since its clock last became due — the newest change of its file, itsFEATURE.mdor MANIFEST.yaml (a re-enable, a new schedule, a released scheduler), so switching it back on is not Down; an entity with a run in flight, with an input request waiting on you, or with no clock-started run on record, is never Down
- Extension Points — every place a brain plugs into the engine is typed, optional and reachable from
defineProject()- Skills — the primary extension mechanism; brain authors think in skills (
SKILL.md+ scripts/MCP), not tools - Tools —
tools: (registry) => voidregistrar, stampedsource: 'project' - Integrations —
integrations: [defineIntegration(...)], tools + webhooks in one bundle - Lifecycle hooks —
lifecycle.onStartup/onShutdownwith a per-serviceLifecycleContext - Ingest sources —
ingest.sources: [defineEventSource(...)]for provider-agnostic inbound events, each optionally carrying its ownverifycredential check in place of the sharedWEBHOOK_SECRET, its own body cap (maxBodyBytes) and per-eventresources; engine-reserved names (mtproto,github,widget) throw - Business KPIs —
kpis: [defineKpi({ id, label, unit, direction, period, goal, read })], up to six numbers read by brain code and shown on the overview; each read isolated under its owntimeoutMs(default 8 s) — kpis.md - Chat policy — per-bot
policy: defineChatPolicy({ chat, sender, engage }), the code tier of the chat frame over normalized signals that never carry an adapter type — chat-access.md - Dispatch targets —
dispatch: DispatchTarget[]for outbound HTTP fan-out - Store curation —
integrationsStore.mcpAllowed(reverse-DNS allowlist over the official catalog) andintegrationsStore.additional(extracustomrows)
- Skills — the primary extension mechanism; brain authors think in skills (
- Engine-Owned Artifacts + Override Sentinels — files the engine regenerates in the brain repo, each with an explicit opt-out
- The set —
Dockerfile+.dockerignore,.github/workflows/deploy.yml,.github/workflows/ci.yml, the synceddocs/engine/tree,docker/compose.{prod,start}.yml.example, and the mirroredskills/engine/corpus - Sentinels —
Dockerfile.override,deploy.yml.override,ci.yml.override,docs/engine.override,docker/.infra-examples.override,skills/.engine-skills.override - Version stamps —
docs/engine/,docker/.infra-examples-versionandskills/engine/.engine-skills-versioncarry the engine version so drift is detectable and self-healing
- The set —
- Brain-Facing Docs Corpus —
docs/brain-dev/is a self-contained doc set carried inside the@omnislash-ai/clitarball and synced verbatim into every brain’s committeddocs/engine/— first byomnislash init, then by everyregen/upgrade;getting-started.mdis its front door and the file doctor hints name, and sibling-only links are enforced by a link-check test - Brain Scaffolding —
omnislash inittakes an empty directory to a runnable, committed brain in three flavors (chat-only,scheduled-ops,pipeline-heavy), guarded by a byte-equality + peer-set drift test so a new brain never starts as a copy of the in-tree fixture- The author-owned tree — templated: config,
constitution/,skills/, the toolchain (biome.json,vitest.config.ts,lefthook.yml), a three-line.npmrcwhose registry scope derives from the engine’s ownNPM_REGISTRYconstant and which carries no auth token - The engine-owned tree — NOT templated:
initinstalls the pinned engine and then spawns the brain’s OWNomnislash regen, so a--version-pinned brain is never stamped by whichever CLI ran the command - The dotenv pair —
.envand.env.exampleare generated from the live env catalog, never templated, so they cannot drift from the schema the engine validates against
- The author-owned tree — templated: config,
- Brain Example —
brain-example/is the engine’s in-tree fixture: a minimal working brain used for self-contained dev (BRAIN_ROOT=brain-example pnpm omnislash dev), smoke CI and the Dockerfile / deploy.yml byte-equality tests
Agents Runtime
- Unified Agent Runtime — CLI agents (Claude Code, Codex, OpenCode) and API agents (LLM providers) behind one interface; the consumer picks via
ModelRef, never by calling a different API- CLI mode — child process per turn, stateful
sessionIdresume, repo required, tools delivered over the engine’s MCP server, resilience via backoff retry + stream-idle watchdog - Long turn for Claude — background work without a pool — the Claude runner hands the prompt to the Agent SDK as a streaming input (stdin stays open) and decides the terminal itself: the first
resultthat arrives with no live background task (the harness’sbackground_tasks_changedset, minus itsambienthousekeeping entries). A backgrounded shell, harness subagent orWorkflowrun therefore finishes inside the turn and the CLI wakes the model with its notification in the same turn (severalresults per turn — each reports its own segment’s tokens, turns and cost, and they are summed — last non-empty answer kept). The set draining closes nothing, because the drain is exactly what triggers that wake-up: a wake-up segment may start more background work and the turn ends at ITS terminal instead, so chained background steps work end to end and only a segment that starts nothing new ends the turn. The whole chain is bounded by oneAGENT_BACKGROUND_HOLD_TIMEOUT_MStotal; the process still lives for exactly one turn, and Codex / OpenCode stay one-shot - API mode — in-process AI SDK stream, stateless (history rebuilt from
conversation_eventsevery turn), tools injected directly as a capability-filteredToolSet, resilience via fallover chains + connection timeout; prompt-cached per chain step in that step’s dialect — the role’s system prompt (shared by every agent of the role), the end of the replayed history, the tool-loop tail — with environment andMEMORY.mdin a standing system message and the daily window / state in each turn’s message, never in the cached system prompt; TTLpromptCache.ttl(engine1h→MANIFEST.yaml→ role) fakeharness — a test/demo harness programmed from the prompt itself with<fake>{…}</fake>directives (text/thinking/tool/sleep/complete/fail/askQuestion/requestGrant/hang/awaitSignalsteps, named transcript fixtures, synthetic usage, structured output matchingjsonSchema), so the whole FSM — streaming, HITL pause, failover, cancellation — can be driven deterministically without burning tokens- Harness-specific adapters — Claude via
@anthropic-ai/claude-agent-sdk, Codex via@openai/codex-sdk, OpenCode via a generic subprocess runner; all three normalize onto oneCliStreamEventunion through shared SDK-shape factories, and each takes the system prompt through its native instruction channel (Claude system prompt, Codex developer instructions, an injected OpenCode instructions file) — sent once, not pasted into every user turn; a changed prompt on a live Codex thread is re-delivered once at the top of the next turn
- CLI mode — child process per turn, stateful
- Crash-Proof Agents (Durable Runtime) — every agent is a Restate Virtual Object keyed by
agentId: kill the server mid-run, redeploy, lose the network — the agent resumes exactly where it stopped- Single durable owner — Restate serializes invocations per key, so the per-agent execution queue is native (no BullMQ queue, no hand-rolled claim/CAS); the VO is the sole writer of lifecycle state
- Same-invocation HITL pause — the
enqueuehandler holds the per-key lock across the entire session including a human wait of arbitrary length, so a pipelinecallbackIdnever leaves handler scope and settles exactly once agentsrow is a projection, not a control surface — status /error/currentInvocationId/currentAuth/currentInputIdsare projected after each transition, then exactly one lifecycle signal is emitted (agent:started/agent:completed/agent:input_request), so observers never wake before the row is readable- Creator attribution —
agents.created_by— every agent row records theActorthat created it (spawning agent, heartbeat, hook, workflow run, pipeline, planning, evaluation, loop, chat, dashboard, system);AgentCreateParams.createdByis required on every create,NULLonly on older rows - Zero zombie runs —
reconcileAgentOrphans(pipelines startup + scheduler 5-min sweep) cross-references each busy agent’scurrentInvocationIdagainstsys_invocationand routes every settle through the VO’s EXCLUSIVEreaphandler, which no-ops if a fresh turn re-claimed the row, purges the dead invocation’s limiter footprint and clears a dead pause’s resume-awakeable - Gap-filled terminals —
settleRunlessTerminalannounces a stream terminal for every terminal with no run to announce it (stop of a paused agent, cancel while parked, preclaim bail,reap), stamping the session’sinputIdsso the chat card it served still closes
- Durable Dispatch Primitives — six verbs on the agent facade, all routed through the per-agent VO over the Restate ingress
agent.enqueue(input, opts?)— programmatic fire-and-forget standalone turn carrying its own callback / schema / opts; a busy agent never throws, the turn is durably serialized behind the per-key lockagent.call(input, opts?)— programmatic request-response awaiting the terminalAgentExecutionResult; dispatched with send then awaited by re-attaching to the invocation on an engine-owned 4-minute window, so it is not capped by Node’s 300 sheadersTimeoutand has no overall deadline (the bound is causal — any invocation that ends answers the attach)agent.send(input, opts?)— the chat verb: append to the agent’s durable inbox and poke the VO’s zero-argrunhandler; callback-/schema-free by typeagent.sendCompact(opts?)— the chat/compact: a compact entry in the same inbox, at the tail (queue mode) or — withinterrupt— at the head after preempting the live turn; served alone as the compaction turn, the outcome on itsturn.completedagent.sendCommand(command, opts?)— a CLI harness’s own slash command (/review,/init) from a chat: an inbox entry of its own (kind: 'command'), served alone with nothing in front of it — no envelope, no merge — because a harness reads a slash command only when it leads the user message; otherwise exactlysendagent.stop()— cancel the live Restate invocation; the VO’s catch aborts the run, settles the pipeline callback and projectscancelledresume— deliberately not a facade method: the HITL gateway readsresumeAwakeableIdfrom Restate’s state introspection and resolves the suspended awakeable directly through the ingress, soft-deferring when the barrier is not yet committed- Dispatch modifiers —
interrupt(cancel the live invocation, then dispatch),delay(deferred dispatch,enqueueonly),idempotencyKey(duplicate sends latch onto the running invocation),signal(caller-owned abort) Agents.task/Agents.oneshot/Agents.recreate— ephemeral fan-out sub-agent (create → enqueue → await the row terminal → soft-delete, one timeout contract with a 24 h anti-wedge backstop), ephemeral inline one-shot, and a fresh deterministic slot per calldispatchAndObserve— dispatch-then-observe helper shared by planner stages and the recovery boss; an already-aborted signal dispatches nothing and returnscancelledinstead of minting a turn only to kill it
- Durable Chat Inbox (Mailbox) —
agents.pendingInputsis the chat’s durable, ordered queue of operations (messages, harness slash commands, and a queued/compact), served by the VO’srunhandler one turn at a time- An entry lives until delivered — read → claim → turn; an entry leaves the inbox once its turn has put it into the agent’s history, so an interrupted turn’s message is never served twice (the next turn sees it, like Esc in an interactive CLI), while a turn that died before delivery leaves its message to be served again; a message is never lost
- A deploy terminates
interrupted, notfailed—interruptedByShutdownrewrites only afailedterminal, and only whileisShuttingDown()is latched, so no failure bubble is posted and anything not yet delivered stays owed; a boot-time sweep then pokesrunonce per agent still owed a turn - Merge semantics —
nextMailboxTurntakes the inbox head: consecutive messages merge (bodies joined with\n\n---\n\n, attachments FIFO, lastrunOptionswins), so rapid sends collapse into one follow-up turn; a queued compact is served alone, a barrier nothing is merged across - One turn, then yield — after its turn the handler pokes itself to the back of the agent’s queue when entries remain, so a message that arrived mid-turn is served next (human intervention in a working agent) while programmatic turns queued meanwhile — auto-compact,
agent_call, hooks — never starve behind a busy chat - Explicit discard —
Agent.stop→stopAgentDiscardingInputdrops input a turn claimed but has not delivered yet (dashboard Stop,/agentsstop button,agent_stoptool);/stopclears the whole column; interrupt preempt,flushMailboxand recovery use the barestopAgent flushMailbox— “send now”: cancel the live turn, then poke, so the queued inbox is served at once- Empty drain is an inert no-op — a surplus poke takes no permit, projects no
running, emits no lifecycle signal and does not touch the row’s terminal projection
- Node-wide Concurrency Limiter — a Restate
agent-limiterVO caps concurrent agent subprocesses across the whole deployment (AGENT_EXECUTION_CONCURRENCY, default 12) as one global pool shared by every agent class- Permits free across a HITL pause — an
awaiting_inputpause returns from the run normally, so the cap counts active subprocesses rather than parked agents - Token + invocation accounting — each permit carries a unique-per-acquisition
token(the release key) and the acquiringinvocationId(the recovery key), sopurge(invocationId)can remove a dead invocation’s entire footprint — held permits and still-parked waiters — without stranding anything under a reused invocation id - Nested permits — a sub-agent spawned from inside a running agent carries
auth.spawnedByAgentIdand takes a non-blocking permit, so a permit-holding parent can never deadlock on a permit-needing child
- Permits free across a HITL pause — an
- Streaming — real-time output via Redis Streams with namespaced subscribers, so UI, planner and workflow observers never interfere
- Stream event taxonomy — one Zod union (
agentOutputEventSchema) acrosscontent.delta,item.*,turn.*,tool.progress,task.*,hook.*,hitl_*,session.cleared,model.rerouted,runtime.warning|error|retry, each carryingeventId/provider/threadId/createdAt/turnId/itemId - Three derived types —
AgentOutputEvent(everything on the wire),AgentEmitEvent(what producers push),AgentConsumerEvent(what subscribers get); the run terminal reaches consumers as anAgentStreamTerminal— aPickofAgentExecutionResultthat deliberately carries no output text - Consume modes —
consume()is ephemeral (auto-stop at the firstturn.completed, for one-shot programmatic observers),consumePersistent()subscribes once and streams across turns with a subscribe-time floor; a persistent request over a live ephemeral consumer upgrades it in place with no teardown - Late-join and replay —
readState/readOutput/readSnapshot, cursor-resumablestreamOutput({ afterId })on a dedicated blocking connection, andstreamOutputManymultiplexing N agents into one XREAD for the dashboard matrix - PG↔Redis checkpoint — every event’s Redis stream id is stamped onto its
conversation_eventsrow, so a snapshot page hands the client an exactlastEventIdand the live tail resumes with no gap and no duplicate - Cross-process waiting —
waitForStateChangecombines the pub/sub bell (fast path) with a periodic durable re-read of the row (correctness floor under an at-most-once bell)
- Stream event taxonomy — one Zod union (
- Conversation Timeline (
conversation_events) — one append-only table is the single source of truth for an agent’s history; every path reads it and everyone writes throughConversationServicerun.emit()3-way fan-out — Redis stream write (returns thestreamId) → PG persistence carrying that checkpoint → derivedEventBusnotifications- Persisted vs transient —
CONVERSATION_EVENT_TYPESdecides what lands (item.completed,turn.started|completed|plan.updated,hitl_*,session.cleared,model.rerouted,runtime.*,task.completed,hook.completed); deltas anditem.startedcollapse into their terminal row - Tool-call pairing —
item.startedis held in memory until itsitem.completedlands, then persisted enriched withtoolName/toolInput/toolCallId - Monotonic
idordering — every reader sorts by the serial id, never by timestamp, and compaction is a prefix delete that keeps surviving rows’ ids, so delivery cursors and SSE checkpoints stay valid - Prompt storage split — the system half is content-addressed in
agent_system_prompts(one row per distinct prompt), the turn half ridesconversation_events.turn_prompt, joined back byte-identically on read; two consecutive turns with different hashes make a cache-prefix break attributable and diffable - Paused-turn ledger row — a HITL pause persists its own
turn.completed{state:'paused'}row (never streamed) so pre-pause spend reaches Audit › Cost and the context meter even if the resume never happens
- Session Management & Compaction — session save/restore, history compaction and transcript management, reported as outcomes rather than silent no-ops
- Session slot ownership — the single
agents.session_idcolumn holds the primary harness’s session (config.refstep 0); a failover step runs withownsSessionSlot: falseand can never stamp its id over the primary’s (the cross-harness session-poisoning fix) session_not_foundself-heal — a resuming step holding an id invalid for its harness clears the slot and re-runs the same harness once fresh, inside the executor, so the chain never burns a circuit-breaker tick on a healthy primary; recognized across Claude / Codex / OpenCode wordings/clearis a rotation — the live conversation is archived, a fresh one is created with asession.clearedback-pointer, the inbox drains and theagent:clearedbell reaches every chat where the agent was active- Threshold summarization —
summarizeIfNeededfolds the prefix intoconversations.summarypastconversation.summarizeTokenThreshold, using the brain-defined summarization prompt and thefastchain, in-layer (text→text, never an agent run, so nested compaction cannot deadlock) compactAgent— one compaction turn on the agent’s queue, API and CLI alike, folding the conversation and (Claude CLI) compacting the harness session in parallel inside it, so no other turn can race the fold; withwaitit returns aCompactionResultreporting the conversation rewrite (with before/after token sizes) and the session separately, since they succeed and fail independently — also carried on the turn’sturn.completed;interruptandwaitare independent knobs, both default off- Compaction markers —
context_compactionitems narrate a compaction on the stream (item.started→ terminal pair for the compaction turn, adeclinedmarker when there was nothing to fold, terminal-only for the finalize-stage summarize and the harness’s own after-the-fact compact), collapsed by the dashboard into one work-log row - Control turns — the compaction turn is dispatched with
turnKind: 'control'+control: 'compact'(the engine operating the agent, not conversing with it), so it records nouser_messagerow, never echoes into a bound chat, is skipped by everything that reasons about the conversation (context policy, summarize, memory flush), and runs on the primary harness only — never the fallover chain, since another vendor has no session to compact and would answer the literal/compactin prose; every other turn — chat,enqueue,call, heartbeat — is aconversationturn, and the chat inbox produces one only from a typed compact entry, never from a message
- Session slot ownership — the single
- Context Window Policy — per-role token marks (
context.notifyAt/context.compactAt) decide what the engine does as an agent’s window fills- Edge-triggered marks — at the turn’s end the agent compares this turn’s
usage.contextagainst the previous turn’s and fires only on the crossing, so there is no “already notified” flag: a compact re-arms the mark by data - Per-key resolution — engine →
MANIFEST.yaml§context→ the role’s owncontext:block, key by key (resolveContextConfig), read lazily, so editing the role file or MANIFEST changes live agents’ policy on their next turn;system:general’s block is its own, not every role’s default (since 1.21.0) - Durable, non-interrupting auto-compact — the agent’s own durable owner decides the crossing and queues the compact turn on itself, journaled: exactly once per crossing, never lost to a restart, never repeated per bot replica, no bot service required; it runs before the agent’s next turn and never kills one.
agent:context_thresholdannounces the crossing; the bot only posts the nudge - Honest denominators — percentages appear only when the runtime reported a window; the nudge names the agent that crossed, because a chat hosts more agents than its active one (heartbeats, hooks, agents switched away from, a business chat’s sub-agents) and
/compacttyped in reply would target a different one; an ordinary sub-agent runs surfaceless, so its nudge reaches no chat
- Edge-triggered marks — at the turn’s end the agent compares this turn’s
- Context Placement (cache-prefix stability) — what goes where is decided by volatility and agent-independence, never by topic, because Anthropic invalidates
tools → system → messagestop-down- Three fields —
systemPrompt(authored, agent-independent),contextSources(environment,MEMORY.md, the period ladder, agent state — tagged sources made of keyed sections, injected into the message stream),turnContext(clock + this turn’s recall) - Per-step, per-section envelope — what goes out is planned per executing step (a failover step and a
session_not_foundself-heal, both brand-new sessions, still get everything) against one map of what the transcript holds,agents.context_marks(section → hash): only the sections that moved are re-sent, as self-describing updates; a gone view or a lost source gets a one-line tombstone; unknown marks (after a compaction or a reset) re-snapshot everything under a header that withdraws the blocks above. A harness that compacts silently (Codex, OpenCode) re-sends a changed source whole - API prompt caching (
promptCache.ttl) —applyPromptCachemarks each API chain step in its owncacheDialect: cache-control on Anthropic native andvercel:anthropic/*, breakpoints on OpenAI GPT-5.6+, none elsewhere; TTL5m|1h, engine1h→MANIFEST.yaml→ rolepromptCache: - Preset-side knobs — the Claude runner unconditionally pins
excludeDynamicSections,CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1andCLAUDE_CODE_TOTAL_TOKENS_REMINDER=off, which is what lets a fleet of same-role agents share one prefix, andCLAUDE_CODE_DISABLE_AUTO_MEMORY=1(Codex:features.memories = false): a harness keeps no memory of its own — an agent’s memory is the engine’s, and a role without amemory:block has none - Break detection —
agent_cache_prefix_breaks_total{level}compares hashedtools/systemprefixes across consecutive turns per (agent, harness) and warns when somebody else’s build moves them
- Three fields —
- Context Budget — per-role breakdown of what fills an agent’s context window before its first message, with findings that name the concrete lever
- Three measured groups — tool definitions (priced by the exact
tools/listbytes for a CLI delivery, the narrower AI-SDK projection for API), the system-prompt cascade attributed section by section fromPromptSection[], and the context envelope by named part promptedTokensvsdeferredTokens— the on-wire turn-1 cost and the search-deferred remainder are never conflated, so a Claude or Codex deployment is not shown as five times heavier than it is; anything known-but-unpriced is countedunmeasured, never zero- Delivery-aware pricing — deferral, grant selection, session traits and the window all hang off one resolved
ContextDelivery(cli+harness(+router)/api, the grant on both arms), derived after the ref resolves; which deliveries defer is one rule,deferredOnTurnOne— on API the role’s loading plan (toolLoading), direct Claude keeps all but its engine-eager and role-eager tools behind ToolSearch, native Codex keeps every MCP tool behind itstool_search, and a routed Claude or Codex step and OpenCode defer nothing (Claude Code turns ToolSearch off behind a non-Anthropic base URL) - Window provenance —
registryorobserved— an API delivery readscontextWindowTokensoffmodels.json, a CLI role is sized by the one window resolver (resolveContextWindows) keyed by the MODEL its delivering step was last measured on (findServedContextModels: delivery ref →usage.context.model, month lookback, derived never stored);window-unknownsplits on the harnessreportsContextWindowflag - Measured prefix beside the estimate —
measured: { tokens, at, outsideEngine }: the first API call of the newest turn of this role, on the step the report prices, that opened a fresh session (usage.firstCall+turn.completed.delivery, read byfindMeasuredPrefixes) — so the harness’s own preset prompt and builtin tools, which no estimate sees, show up asoutsideEngine(measured − estimate: harness floor + estimate error), a line and never a verdict; on Codex only a fresh turn that called no tool qualifies (its usage is one sum per turn) - Findings —
prefix-over-budget,no-tool-deferral,unused-tools(per role, from the spend ledger; silent for a role that ran nothing in the window),unmeasured-tools,skills-truncated,skills-catalog-heavy,state-truncated(state views thestate.charsbudget cut or stubbed),memory-heavy,role-heavy,window-unknown,mcp-servers-unavailable, and the footprint flagswildcard-capabilities/wildcard-skills('*'),memory-unused(memory declared, no memory tool called),grant-pinned(live agents of the role holding capabilities beyond its file) andunconfined-builtins(a Codex / OpenCode ref whose own tools ignore the grant); every threshold is the role’s resolvedcontext:config (engine → MANIFEST → role) - Usage beside the footprint —
agent.context(toolagent_context,runtime:read) answers each role’s report with its runs, the tools they called and the skills they opened (skillsOpened, besideskillRuns— the turns that recorded their opens; a Codex or OpenCode turn records none) in a window (the one window shape every period read takes, default the last 7 days), fromusage_recordsby role — runs counted asusage_breakdowncounts turns, tools by the bare nameusage_toolsnames them, spend left tousage_breakdownby role (usage:read) — numbers that survive an agent’s reap and a compaction — and “N tools / M used” (offered, on turn 1, deferred, called) - Trim to used —
role.trim(toolrole_trim,runtime:read) previews arole_updatepatch: theskills:list narrowed to the skills the role’s agents opened (recorded per turn inusage_records.skill_loads; observed on API and Claude CLI, left alone on Codex / OpenCode and while any run in the window did not record its skill opens — a fallover to Codex may have used any listed skill), deferred tools called on most runs proposed fortoolLoading.eager, the diff and the tokens every turn sheds; it writes nothing — the patch commits throughrole_updateagainst the previewed sha - Feature contexts carried (
contextUse) — per role, each feature context’s attached and stubbed turns against the turns that recorded contexts (usage_records.contexts), mean rendered chars, links and stubbing knobs; a stub raisescontext-stubbed, shown on/system/context - Surfaces —
/system/context(one tab per role with its usage and a Trim to used preview, unresolvable and failed roles reported separately) and aContext budgetdoctor check that grades the heaviest role’s on-wire tool definitions, each role priced under its own delivery (tools a direct-Claude or native-Codex role keeps behind its search tool, and an API role pasttoolLoading.deferAbovebehindtool_search, do not count; an OpenCode, routed-Claude or routed-Codex role’s whole set does, and so does an API role at or under the threshold); its hint names the lever for that role — a deferring harness ortoolLoading, a narrowercapabilities:grant orconstitution/engine-tools.json— and the skills to turn off when the catalog cap dropped some - Role-prompt budget —
promptBudget.roleChars(engine 6000 →MANIFEST.yaml§promptBudget→ a role’s ownpromptBudget:,0= off): a role body over it raisesrole-heavyon/system/contextwith its three largest##sections, and doctorContext budgetwarns naming it — observation only, never a failure. Writing guidance: role-prompts.md
- Three measured groups — tool definitions (priced by the exact
- System Prompt Cascade — one
composeSystemPromptbuilds the prompt for the CLI path, the API path and the budget report, so a report can never describe a prompt nobody sends- Modules — identity → role → background-tasks/environment → chat delivery → memory instructions → language → question policy → skills catalog → per-row instruction append, joined with an explicit separator and each tagged with a closed
PromptSectionId - Per-harness question policy — a different AUQ enforcement file per harness (
claude/codex/opencode/ generic / API / disabled), because tool names and footguns differ per runner - Background-tasks module — CLI-path runtime truth about background work, per harness: Codex / OpenCode get the one-shot rules (the subprocess dies at the turn’s end, with the live
AGENT_STREAM_IDLE_TIMEOUT_MSrendered into the polling guidance); Claude gets a half that states ONLY where the engine diverges from what its own harness already teaches — nothing runs between turns (it names no harness tool — the timer builtins are never offered), with theAGENT_BACKGROUND_HOLD_TIMEOUT_MStotal (omitted when the idle watchdog is off). Both halves carry two tool bullets that follow the grant: “come back later →schedule_create_prompt” only withschedule:write, “too long →agent_task/agent_create+agent_enqueue+agent_await” only withagent:executeandruntime:read— a role is never told about a tool it is not offered - State module + modelling guide — a role with
state:gets a short module (what its namespace owns, which views render, the determinism rule) plus one pointer to the unlistedagent-stateengine skill for the long guide — present only when the agent can load it (CLI always; API withskill:read) - Chat-delivery module — runtime truth for an agent a person talks to (
agents.source = 'chat'): the person reads the turn’s final message, so it carries the whole answer and stands on its own (never “see above”); text between tool calls is only passing live status; bookkeeping goes before the answer, because text written after it replaces it; the text before a question is delivered with the question — plus a per-surface status-card half. It agrees with the CLI harness’s own final-message convention instead of overriding it, and adds what no model can observe from inside a turn; carried regardless ofpromptFlags
- Modules — identity → role → background-tasks/environment → chat delivery → memory instructions → language → question policy → skills catalog → per-row instruction append, joined with an explicit separator and each tagged with a closed
- Per-Role Prompt Flags — role/agent frontmatter gates which system-prompt modules the agent gets, and turning one off skips the upstream work, not just the text
- Seven flags —
identity,environment(also gates background-tasks),dateTime,questions,capabilities(the requestable-capabilities module;falsedrops the list, never the request tool),skills,language skillsis three-valued —'engine' | 'harness' | 'both'decides which of the two skill systems delivers what the role’sskills:list grants on a CLI turn (the engine’s policy-gated catalog vs the harness’s ownSkilltool and self-discovered.claude/skills/), because “both catalogs live” must be a choice rather than an accident; there is no off value — noskills:list is no skills (a stalefalsefails the role load)- Memory and skills are declarations, not flags — what an agent remembers is the top-level
memory:block, which skills it sees theskills:list; no block, no memory, no list, no skills - Resolution — engine defaults + per-role override by a pure
resolvePromptFlags, every flag replaced wholesale; defaults reproduce today’s cascade byte-for-byte, locked by a snapshot test - Loop roles ship without memory — the proposer (
loop/role-proposer.md), the implementer (loop/role-implementer.md) and the judge (role-evaluator.md) declare nomemory:block, so a round is reproducible and the judge’s rubric holds nothing unpinned;omnislash doctor→ Loop role memory warns when a brain copy declares memory for the proposer / implementer, fails for the judge
- Seven flags —
- Agent Roles — a role is what an agent knows how to be; system roles ship with the engine, brain roles are markdown files
- Engine system roles —
general,chief,planner,coder,reviewer,researcher,evaluator,loop:proposer,loop:implementer,memory,consolidator,boss,editor-assistant,git-conflict-resolver,browser,remediation— a closed set: a brain overrides one by shipping its own copy of the file, and its own roles are brain roles (features/<feature>/<name>.agent.md); arole-*.mdunder any other id is skipped and named byBrain entities(with the rename when the id was renamed — a stalerole-browser-operator.mdorrole-dashboard-guide.md). Namespaces are subdirectories: each:aftersystem:in a role id is one directory underconstitution/Agents/system/, sosystem:loop:proposerisloop/role-proposer.mdand a brain overrides it with its own copy atconstitution/Agents/system/loop/role-proposer.md(arole-proposer.mddirectly undersystem/is a different role,system:proposer) - Brain roles — user-defined personas as
<id>.agent.mdfiles inside a feature directory (features/<feature>/) (name, description, body,modelRef,skills,capabilities,mcpServers,promptFlags,context,promptBudget); brain roles take priority over system defaults - Three-layer inheritance —
system:generalfrontmatter → role → the agent’s own overrides; nothing is pinned: the model and the grant resolve from the role file on every turn (capabilitiesis the exception to the ladder: the role file’s own list, neverrole-general.md’s — no list means no capabilities, everything means an explicit'*'), so a role-file edit reaches live agents on their next turn; the row keeps only overrides — a model chosen for the agent (modelRefat create,/model, the edit form; empty = follow the role), capabilities added on top of the role’s, and an agent-spawned child’s ceiling (its spawner’s capabilities, for life);skills/mcpServers/promptFlags/context/ prompt body are read per run the same way - Role writes — one path, by agent or page —
role_updateedits a role file (brain role in place; asystem:role into your brain’s copy, the first write copying the engine template — the dry run says so) — name, description,modelRef,capabilities,skills,memory,promptFlags,toolLoading,promptCache,promptBudget,sessions,context,mcpServers, body — never the moneybudget:, which isbudget_update’s (all threerole_*writes refuse the key);role_createmakes a newfeatures/<feature>/<id>.agent.md. Each is dry-run → confirm → compare-and-swap on the file’s sha, refuses a file the role loader would skip, and answers what the write means: the grant it resolves to, the memory it declares, the fork, a brain-wide model inMANIFEST.yamlthat shadows the file’s, and for the loop’s own roles what the loop does differently. A write that ADDS capabilities or MCP servers, or moves the role onto a model whose command-line agent (Codex, OpenCode) keeps its own shell, file and web tools whatever the grant says, isrole_widen(role:widen, a person approves) —role_update/role_createrefuse it, because every live agent of the role holds the new grant from its next turn. The dashboard’s agent pages (/agents, an agent’s Definition and Context), New agent dialog and Settings → Agent defaults cards call the same ops, and show a widening’s delta to the person before it lands. Since 1.23.0role_write_filereplaces a role file whole (the raw editor’s save, held to the same checks; a widening isrole_widenwithcontent),role_reset(role:widen— the template may grant more) deletes your brain’s copy of asystem:role so the engine template serves again - Role reads (since 1.23.0) —
role_list(feature:read) lists every role, the engine’ssystem:roles included (featureengine), each with itssource(brain,engine= on the engine template,engine-customised= your brain keeps its own copy), the model it resolves to, how many skills and capabilities it holds and which it declares (declared), whether it declares memory, the MCP servers its agents connect to (mcpServers, by name), the scenarios / heartbeats / hooks that dispatch to it and the chats it serves (a bot’s own role, anaccess.yamlroute, a secretary counterpart);role_getadds its frontmatter as written, body,{{placeholders}},pathandsha(forked= the text is the engine template and the first write copies it), orbrokenfor a role file that does not load (itsidandsourceanswered either way);role_read_file(feature:read) is the role file as written with thesharole_write_filetakes (for asystem:role you never customised, the engine template —forked) - Picker reads (since 1.23.0) —
model_list(config:read) lists every ref amodelRef:may take that can dispatch now: themodels.jsonrole words (API first, then CLI), eachcli:<role>, the aliases, the API models with a configured provider and the CLI harnesses, each with its mode and what it resolves to — since 1.24.0 also the roles whose model names it (usedBy), a model’s provider, context window, pricing, rate limit and whether it is hidden (meta), and the speech / transcription chainsdefineProjectdeclares — and the roles with a voice (roleSpeech) or a transcription chain (roleTranscription) of their own;skill_list(skill:read) lists the installed skills, each with its source, whether it can run here and the env vars or binaries it is missing — since 1.24.0 also whether it is switched on, what it is missing by remedy (eligibilityIssue), where the lockfile pins it and whether its directory drifted, the roles that list it (usedBy) and when an agent last opened it (lastLoadedAt, withloadsObserved: falsewhen no turn that could have shown an open ran), plus the lockfile entries missing on disk - Role handlers (per-role extension point) — an
AgentRoleHandlercan be registered against any role (registerRoleHandler) to install agate()that short-circuits a turn before any cost; the boss handler skipping a stale wake-up returns a quietcancelledthat is still announced on the stream
- Engine system roles —
- Playwright MCP Browser — headless browser as a sidecar MCP service: agents get
navigate,click,read,screenshotacross all CLI providers; toggleable viaservices.playwright - Browser Role — engine-shipped
system:browserwith the browser pre-attached and an anti-prompt-injection prompt; any agent spawns it withagent_task, engine code withAgents.task— no per-brain role authoring, and the only role that can carry MCP servers - Full Desktop (computer use) — not shipped; a brain plugs one in as an HTTP MCP server on a role: a managed cloud desktop, or its own desktop container added through a compose override. Recipe, sizing, file hand-off through the artifacts root and approval rules: computer-use.md
- Per-Agent MCP Servers — the engine injects its own
omnislashMCP entry per invocation with the caller’s auth context as URL query params, and merges the brain’s declared servers under it- Merge priority —
.mcp.json(Claude only) < agent.agent.mdfrontmattermcpServers< the engine-managedomnislashentry; an agent-declaredomnislashkey is dropped with a warning - Per-harness delivery — Claude via the SDK
mcpServersargument, Codex via the in-memoryCodexOptions.config, OpenCode viaOPENCODE_CONFIG_CONTENT;${VAR}placeholders interpolate from the environment - The declared set is the whole set (Claude) — sessions launch with
strictMcpConfig, so the CLI mounts no claude.ai account connector, plugin or user-scope server on top; a declared server short ofconnectedat session start is warned by name
- Merge priority —
- Structured Output — Zod-schema JSON responses from any agent, alongside plain text, persisted on the terminal
turn.completedand mirrored onto the agent row- Runtime normalization —
normalizeOutputSchemastrips every constraint no strict provider accepts, forcesadditionalProperties: falseon closed objects, and rejects recursion and non-object roots at two call sites (agent dispatch, tool registration) - Open-map policy split — structured output rejects a
z.record(an unsatisfiable closed map would be silent corruption), while tool schemas preserve it as a genuinely open contract - Provider constraint matrix — documented per-provider support and limits (Claude strict-tools/optional/union budgets and compile timeout; OpenAI property, depth, enum and character caps) plus the safe cross-provider subset
- Codex double-envelope unwrap — Codex sometimes satisfies a strict schema by serializing the real object into one of its string fields; the engine detects and unwraps it structurally, logging every hit, so brains need no unwrap of their own
- Runtime normalization —
- Model Fallback & Retry Resilience at the run level — failure is classified and surfaced, never swallowed
classifyError— one shared oracle answering two orthogonal questions about a failure:retryable(worth another attempt against the same provider, after a delay) andfailover(worth the next step of a fallover chain). It coverstimeout,rate_limit,session_not_found,bad_request,authentication_failed(401 / invalid key / expired token) andbilling_failed(402/403, credit or key limits,insufficient_quota), plus provider reset-hint parsing (“resets 3:10pm (Europe/Amsterdam)”, “try again in 5 minutes”,Retry-After) into a real delay. On the API path it reads the status code and the provider’s own error code off the parsed transport error rather than hunting for them in the message text, which no longer contains them — the harness-shaped text patterns stay first-class for CLI agents, and the order is provider code → text → status (Anthropic bills an exhausted balance as HTTP 400, so status may not outrank what the message says)- Fallover is fail-open — a chain advances on anything the oracle does not positively rule out, including failures it cannot classify at all. Only a real 400 (a malformed request fails identically at every step), a stale CLI session (which self-heals on the same harness) and a caller’s own cancellation stop a chain. So a provider that runs out of money, has its key revoked, or fails in a way nobody has a pattern for yet costs one step, not the turn — the multi-provider
apiChainsyou declared inconstitution/models.jsonbehave the way declaring them implies - Stream-idle watchdog, both execution modes — a turn emitting no stream event for
AGENT_STREAM_IDLE_TIMEOUT_MS(default 15 min, reset by every event, 0 disables) fails as a retryabletimeout, killing a wedged CLI harness or aborting a silent API stream and releasing itsAGENT_EXECUTION_CONCURRENCYpermit. A Claude turn held open by live background tasks is silent by design, so while the harness reports live work the budget in force isAGENT_BACKGROUND_HOLD_TIMEOUT_MS(default 1 h,0= the idle budget, inert when the idle budget is 0) — a TOTAL for the turn rather than a per-gap window: a deadline armed the first time work goes live, so a model chaining wake-up after wake-up spends one budget between them instead of renewing it per gap. The same retryabletimeoutwhen it trips (the retry starts with a fresh budget), and a deployment value because it bounds how long background work can hold a permit and an idle CLI process. Aresultwith a HITL pause armed ends the turn regardless of live tasks — a paused turn keeps the shape it always had - In-process backoff retry —
retryWithExponentialBackoffre-runs a step and banks each discarded attempt’s usage segments so burned tokens still reach Audit › Cost - Resilience events —
model.rerouted(withfromModel/toModel/reasonfailed|circuit_open/ the real triggeringerror) andruntime.retry(provider/attempt/maxAttempts/errorCategory) are first-class stream events reaching both Redis and PG - A stream error is a failed turn — an AI SDK
errorpart is terminal and rides the result into oneresolveOutcome, so a dead turn isfailedwith classification instead ofcompletedwith empty output; text and per-step usage produced before the error survive - Tool failure inside a turn is part of the result — failed tool calls are carried on the turn’s outcome (and onto
meta.heartbeat.toolFailures) rather than being a line in the logs, so a tick whose delivery tool failed cannot read as green - Mode-independent tool ledger —
AgentRun.toolCallscounts calls per tool name (keys in first-call order) atemitDerivedEvents, the one choke point both CLI and API tool results pass;runDispatchderives the result’stoolsUsedandhasToolUsefrom the same keys, so the list and the boolean cannot disagree, and the counts go to the spend ledger
- CLI Failover with Trace Handoff — the next harness in a chain picks up the execution trace and continues rather than restarting
- Handoff file — the timeline is drained, serialized to a temp markdown file (led by the conversation’s summary once a compaction folded part of it), and the next step’s prompt gets only a
<handoff-context>pointer (the harness reads the file with its own Read tool); all handoff files are cleaned in afinally - Usage banking — failed attempts’ usage segments fold chronologically into the final result, so the run’s single ledger row includes burned attempts with no double count
- Same-harness continuations skip it — the SDK
sessionIdresume already carries the history
- Handoff file — the timeline is drained, serialized to a temp markdown file (led by the conversation’s summary once a compaction folded part of it), and the next step’s prompt gets only a
- Finalize Pipeline — a fixed, composable sequence of post-dispatch side-effect stages with an explicit per-stage error policy
- Stages in order —
writeOutputFile→emitTurnCompleted→recordUsage→drainStream(fatal) →summarizeConversation→flushMemory(background); the context-window policy runs after them, in the agent’s durable owner - Side-effects only on the VO path —
voFinalizeStageswrites no ownership state; the VO owns status, the row projection and the lifecycle signal - Brain extension point —
AgentExecutorDeps.extraFinalizeStagesrun after the defaults so they observe fully committed state
- Stages in order —
- Per-Call Run Options — a single dispatch can override provider and prompt behaviour without touching the agent row
- CLI knobs (
opts.cli) —webSearch(falsedrops Claude’sWebSearch— it never widens pastresearch:read/ CodexwebSearchMode),reasoningEffort(the per-invocation override of the ref’s@<effort>) - Prompt knobs —
systemPrompt(replace the engine cascade entirely, including the per-row append),skills(replace the role’s skills list for this run — set by a scenario’sskills:andagent_task, never on a chat turn),agent.role(re-resolve prompt / skills / MCP for this turn) - Behaviour knobs —
interactive: false(non-interactive autonomous mode: the agent cannot ask questions),outputSchema(raw JSON Schema on the wire; a Zod schema in the pipeline SDK’srunAgent/safeRunAgent, which returns the output typed and parsed),outputFile(auto-write the structured output or text result to a path),memoryFlush,auth(execution identity merged into the run’sAuthContext),runIn
- CLI knobs (
- Agents That Manage Agents — sixteen engine tools let an agent spawn, drive and supervise child agents, so multi-agent hierarchies get real oversight instead of fire-and-forget
- Spawn & drive —
agent_create(the opagent.create,agent:execute: a role, an optional name, model, lineage, repo,runInand firsttask;ifExists: 'reuse'with anamemakes it get-or-create — the live agent of that name is answered as it is (reused: true) instead of refusing the clash;dryRun: trueanswers what the agent would get and whichrunInrungs this brain can run, and why not — the dashboard’s New session is the same op; an agent creating one caps it at what it holds),agent_task(ephemeral: create + run + await + auto-delete),agent_call(await a turn on an existing agent; returns onawaiting_inputlikeagent_task),agent_enqueue(fire-and-forget),agent_await(block until terminal or a chosen status),agent_stop,agent_delete,agent_update - Answer & escalate —
agent_answer(answer a descendant’s pending question; the child resumes with Agent’s answer…) andagent_escalate(hand a descendant’s question to the person now, with a note the card shows above it);agent:execute, descendants only, never grant requests — see Sub-agent questions under HITL - Observe —
agent_list(the opagent.list: one row per session — or per lineage / run withgroup— with its role’sfeature, its status in words (word: idle, working, needs you, stuck, failed, done — a session in a turn with a request it opened waiting says needs you, never stuck), spend, queued messages, pending requests (the ones waiting on a person) with the oldest named, who holds it if intercepted, its parent andorigin; the Boss session and live ones lead every sort; filters by status /word(what the rows say, Stuck included) / source / role / feature / lineagegroupKey/ nameq/run/entity/parentId/ one bot’schat/ intercepted / a window,deleted: truefor soft-deleted ones; keyset pagination;counts: true— how many rows each word lists under the same filters),agent_summary(the opagent.summary: counts per status and, withwords: true, the lineages per word (words), the working ones, the lineages and the intercepted ones, the window’s sessions active and started, failed and waiting with what agents spent beside the window before, every figure narrowed to one role withrole, and withby: 'role'each role’s sessions and newest activity),agent_get(the opagent.get, byidorname: the row with its status in words, model, grant, spend, context fill and reap time, plus what it is doing now —current:lastAssistantText,currentItem,pendingHitl({ hitlId, kind, questions, deliverAt }of the pending interaction),partialUsageand a streamcursor— the messages scheduled to it and the resource watches it holds),agent_history(the opagent.history: the transcript page by page, of the live conversation, an archived one or a chat episode; a request’s events — its payload and answer — only for a caller holdinghitl:read),agent_tail(the opagent.tail: cursor-based projected event forensics),agent_prompt(the exact system and turn prompt a dispatch ran with, and what moved since the one before),agent_export(the whole record as one JSON document, its requests withhitl:read); all the reads are grant-free (runtime:read) — a request’s content ishitl:read’s - Name resolution — names are unique per chat scope, not globally: the reads address an agent by
id;agent_get({ name })resolves a chat-originated call to that chat’s agent and refuses an ambiguous global name with the candidates’ ids instead of picking one - Stuck — a working session with no activity for its role’s threshold reads
stuck: engine 600 s →constitution/MANIFEST.yamlsessions.stuckAfterSeconds→ a role’s ownsessions: { stuckAfterSeconds }; observation only - A turn a deploy cut off continues — the engine resumes it on the same session with a note of what died with the process (harness subagents, tool calls in flight) and which sub-agents still live, at most
sessions.continueAfterRestarttimes (engine 3 → MANIFEST → role;0= never), then ends itinterruptedand says so in the chat; brain-sync holds a push that would deploy while a turn runs, up tobrainSync.deployHoldMinutes(60); a turn card the restart left closes at the turn’s end - MCP agent resources —
omnislash://agent/{id}/statusandomnislash://agent/{id}/outputexpose the same reads as MCP resources, scoped to the caller’s spawn family (thecreated_bytree: its root and every live descendant — a parent sees its children, a child its parent and siblings)
- Spawn & drive —
- Agent Grouping (
groupKey) — every agent row carries a lineage key so a planner that ran fifty times reads as one agent, not fifty impostors- Lineage rule — rows under one key must be a time-series of one logical agent (at most one live at a time); canonical shape
prefix:instance(hb:<id>,hook:<id>,chat:<deliveryKey>,thread:<deliveryKey>:<root>,wf:<taskRunId>:<slug>), with deliberately coarse buckets allowed for disposable machinery - Optional with a safe default — omitting
groupKeymakes the agent its own lineage (groupKey = name); at least one ofname/groupKeymust be given, each deriving the other, and a forgotten key costs an extra row rather than a hidden agent - Orthogonal to
role— one group can contain sessions of different roles, and a role can evolve inside a group - Not the spawn family — a child spawned through
agent_create/agent_taskis its own lineage, so a live parent never disappears behind its running child; who spawned whom iscreated_by, and the family (MCP resource scope, retention) is that tree - Per-instance meta —
agents.metaJSONB replaces the droppedagent_boss_runsandheartbeat_runstables (meta.boss,meta.heartbeat), with a partial unique index onmeta->'boss'->>'status'enforcing the single-active-boss invariant
- Lineage rule — rows under one key must be a time-series of one logical agent (at most one live at a time); canonical shape
- Agent Retention & Reaping — a periodic sweep in the
pipelinesservice (6 h) soft-deletes idle transient agents and hard-deletes expired rowsagentRetention.ttlHours— inactivity TTL againstupdatedAt, which auto-bumps on every status transition / inbox push / heartbeat, so an active chat never expires- Shape-aware deletion — headless
hb:/hook:tick rows and ended boss rows are hard-deleted without a soft-delete phase (no undo UX to preserve); everything else soft-deletes first and stays readable until reap - Retention follows the spawn tree — an idle child whose parent row is gone or soft-deleted is transient too, so a persistent child of a heartbeat tick or a thread agent goes with it rather than living forever
- A watching agent is waiting, not idle — an agent holding a resource watch (a subscription to the events of an external resource, e.g. a PR it opened) is exempt from both the soft-delete and the reap, however long it stays silent; every watch carries its own expiry, so the exemption is bounded, and deleting the agent removes its watches with it
- The agent row owns its history —
agent_conversations.agent_idis an FK withON DELETE CASCADE, so a soft delete keeps the whole transcript readable while a hard reap takes the agent’s conversations, theirconversation_eventsand theirhitl_interactionswith it. Grants minted from those approvals survive (capability_grants.hitl_idisON DELETE SET NULL) — a retention sweep must not silently revoke an authorization - Pending cards are closed before the row is destroyed — a DELETE cannot run a lifecycle, so the sweep scans the expiring ids, has the HITL gateway drive every pending interaction under their conversations
cancelledwith a durable reason (the owner’s card finalizes as ”🗑 Agent removed”, a coalesced row’s joiners get their fan-out, a pendinginterceptionrow hands its workflow run back), and only then deletes by id. A card never vanishes without a verdict, and an unanswered card is never an immortality token — the row is reaped either way - Sandbox-aware — the sweep runs where
$RUNS_ROOTis mounted so a reaped agent’s checkout is actually removed, backed by a durable filesystem sweeper for orphan directories
- Attachments End-to-End — files (photos, audio, documents) flow from any ingress → blob storage → agent → dashboard conversation, with one canonical persistence type
- Two types, one boundary —
MediaAttachmentis transport,AttachmentRef{blobRef, mime?, filename?}is persistence;toAttachmentRefsconverts once inAgentRun.begin()and nothing downstream sees the transport shape (its inversefromAttachmentRefssits beside it, for replaying a persisted turn) - Clean
content— the DB column holds exactly what the user typed; attachments live inmetadata, so analytics never has to regex an “Attached files:” splice out - Transit splice, not storage splice — a CLI dispatch appends absolute file paths to the prompt at the subprocess boundary, and the cross-harness handoff serializer rebuilds the same format from metadata
- API multipart with history round-trip — every prior user message with attachments is rebuilt as multipart
UserContent, not just the current turn;local://refs are read from disk, external URLs are handed to the SDK - Blob storage abstraction —
BlobStorageRouterroutes by scheme with a content-addressed local provider;resolveLocalBlobRefcanonicalizes and contains every ref at each trust boundary (ingress, egress, retrieve) file_send— the tool an agent uses to send a file back; it blocks until the document is actually delivered and returns itsmessageId, so a failed delivery is a failed tool call
- Two types, one boundary —
Models & Model Routing
ModelRefGrammar — one closed-format string names an agent’s model everywhere: the create wizard,/model, role frontmatter,models.jsonchains and theagents.model_refcolumnmodel_update(config:write) — writesconstitution/models.jsonfrom its sections (models,roleAliases,apiChains,cliChains; a section left out and any other top-level key are kept), refused asinvalidwhen the file would not load as the runtime loads it (schema,@<effort>suffixes, case-only key collisions), and asrefusedwhen an existing model name — a role, an alias orcli:<role>— resolves onto a Codex / OpenCode harness it did not reach before (an alias re-aimed or added over a role, a chain gaining a step, an API chain removed so the bare role falls through to its CLI chain), which keeps its own tools whatever a role’s grant says — that write ismodel_widen(role:widen, a person approves); D30-shaped. The dashboard’s Models page calls the same op, and on that refusal shows it to the person and saves throughmodel_widenonly on their yes- Shapes — bare CLI harness (
claude,codex,opencode,fake), harness-through-router (claude:openrouter:x-ai/grok-4.1,claude:vercel:anthropic/claude-opus-5), API provider+model (openai:gpt-4o,vercel:anthropic/claude-opus-5), role word (smart,fast,smartest,cli:smart), literal fallover chain (claude@high,codex@medium), each step optionally suffixed@<effort> - An aggregator name means two things, and position decides which —
vercel:anthropic/claude-opus-5is an API step (we dial the gateway ourselves),claude:vercel:anthropic/claude-opus-5is a CLI step routed through it; the middle segment is only ever read as a router AgentRefcanonical form —readonly [Step, ...Step[]]; a single ref is a chain of length 1, so nothing downstream has a “with-failover” special caseharness≠provider— a CLI step carries aharnessfield (the subprocess wrapper), an API step carries aproviderfield (the AI SDK slug); the two are never overloaded onto one name- Closed-world, case-insensitive parse — structural segments fold to lowercase and the model tail passes verbatim; every name must be a known harness, provider, router, role or alias, otherwise
parseAgentRefthrows at parse with the expected forms and a did-you-mean hint, so nothing silently parses as a nonexistent role - Chain rules — same-mode only, all-concrete, roles forbidden inside a literal chain (roles are themselves named chains); chains are stored comma-separated in the same single column
toDispatchableModelRef— the availability gate left after parse (provider env keys present, router env keys present, chain non-empty), kept separate from the grammar gate
- Shapes — bare CLI harness (
- Catalogue and provider reads (since 1.24.0) —
model_read_file(config:read) ismodels.jsonasmodel_updatetakes it (every section, theshaa commit passes asexpectedSha);model_summary(config:read) says which step of each API chain (smart) and CLI chain (cli:smart) served the turns over a window (default7d) — each step’s turns and share by the model that finished them, the turns of the chain’s roles that ended off it (otherTurns) and the fallbacks taken, with the note that a model in two chains counts in each;provider_list(runtime:read) is every LLM provider now — key set or not, its circuit read live (openwhile it blocks calls, with when it lets one through;half-openpast that), its models’ calls in the current minute against their rate limit — plus every other circuit breaker (tools, memory embeddings) and each API chain with the steps an open circuit holds;provider_get({ id })adds the chains through it in words, its spend and turns over a window, the fallbacks taken from it and its key in the credential inventory (no latency or error rate: nothing durable records them);provider_historypages the circuit-breaker, fallback and auth-issue events, one provider’s withprovider. The dashboard’s Models and Providers pages read the same ops - Model Registry (
models.json) — the brain-owned catalogue of models, roles and chains the engine reads for every API dispatchModelEntry—id(provider:model),label,providerLabel, optionalhidden, optionalrateLimit.rpm, requiredcontextWindowTokens, optionalpricing(per-1M input/output/cacheRead/cacheCreation, optionalcacheCreation1h— the 1-hour cache write rate, 2× input on Anthropic — or per-image), optional defaultreasoningEffort, optionaltimeoutMs(the brain tier of the per-attempt budget — declare it for a model whose first output legitimately arrives late; floored at 1 000 so a seconds-shaped typo fails loudly instead of aborting every call to that model)roleAliases/apiChains/cliChains— role names map to alias targets or to ordered fallover chains (API entries may carry an@<effort>suffix, validated at load); role names are case-insensitive with a fail-fast collision check- Availability filtering — API chain steps whose provider has no configured key are dropped at load, while the declared role vocabulary stays unfiltered so the parser still recognizes the name
- One error voice, three surfaces —
parseModelsFileproduces the same actionable message for the loader throw, theModels filedoctor check (which fails on the real schema, not a shape guess) and the dashboard models editor - content-keyed cache — the file is read on every accessor and re-parsed only when its bytes differ, so editing models does not need a restart
- Role-Based Fallover Chains — a role word resolves to an ordered list of concrete models and the engine walks it on failure
- API chains (
tryChain) — advance on a failover-worthy error, throw on a non-failover error, at the last step, or when the caller’sAbortSignalhas fired (decided by the signal, never by the error’s shape — a cancelled caller gets their own reason back, and the call’s rate-limit hold and retry backoff end with it);onExecutedrecords which model actually served,onFallbackfires on every transition - CLI chains (
cliTryChain) — iterate harness steps behind a circuit breaker, dropsessionIdon every failover step, and hand the next harness a trace handoff file - Failover classification — timeouts, rate limits, circuit-open, 401–403, 429, 5xx, 404/410 and network errors are failover-worthy; 400 (schema/validation) and abort are rethrown raw
model.reroutedtelemetry — carriesfromModel,toModel(absent = chain exhausted, no retry follows),reason(failed/circuit_open) and the actual triggering error (absent on a circuit-open skip)- A dead credential reaches a person, and is fixable where it lands —
CliAuthIssueNotifier(chat-core) subscribes toagent:auth_issueand posts to the status surface as a notice about the model’s credentials (one message however many agents trip over it, repeats folded, said again afternotify.remindAfterHours); a CLI step’s notice carries a Sign in button into the in-chat sign-in below and names the dashboard-terminal login andomnislash auth-agents <harness> --forceas alternatives; an API step’s (and a key-authenticated harness’s — OpenCode) remedy is its provider key,billing_failedoffers none - A fallover reaches the user who was waiting on it — both modes ring
agent:fallbackfrom their dispatch strategy (makeFallbackEmitter), andModelFallbackNotifier(chat-core) posts a standalone chat message naming the transition, the reason (failed/circuit_open) and the provider’s own error, into the run’s OWN surface (status surface for a run with no chat) as a notice — on a run’s chat about the agent and its transition, on status about the model, so one outage is one message whichever heartbeat agents hit it — the live status card shows the same transition for the seconds before the next tool overwrites its action slot, so it is not a record of anything - Bare role resolves API-first, then CLI —
cli:<role>is the explicit disambiguator
- API chains (
- Reasoning Effort — one 5-level scale (
minimal | low | medium | high | xhigh) expressed in the same ref grammar and mapped per provider- Per-step suffix —
claude:fable@high,openai:gpt-5.2@low,smart@high,claude@high,codex@medium; split on the last@, case-folded, closed-world (a typo throws at parse) - Precedence, per mode — CLI:
opts.cli.reasoningEffort→ the step’s ref effort → harness default; API: the step’s ref/chain-entry effort → the role-step default →ModelEntry.reasoningEffort→ provider default - Provider mapping — Anthropic API →
thinking.budgetTokens(2k/5k/10k/30k/60k), OpenAI and OpenRouter →reasoning_effort(all five, normalized per routed provider), Codex →modelReasoningEffort, Claude CLI → SDKeffortwithminimalclamped tolow, nvidia / OpenCode / fake → ignored - Vercel is mapped by the model’s vendor, not the provider — the native gateway forwards provider options under the real vendor’s key, so
vercel:anthropic/…takes a thinking budget andvercel:openai/…takesreasoning_effort; any other gateway vendor ignores effort, like nvidia - Applied per step, not per call — the fragment is injected into that step’s own
doGenerate/doStream, so every fallback step runs with its own effort
- Per-step suffix —
- LLM Provider Factory — lazy factory for AI SDK models wrapped in a
ChainModelthat drives the resilience pipeline on every call- Three resolution modes —
model(role)(config chain),model('provider:model')(single-step, no failover),chain([steps])(explicit ordered list); all three return the same shape - Supported providers —
openai,anthropic,openrouter,nvidia(with an optional custom base URL) andvercel(Vercel AI Gateway,AI_GATEWAY_API_KEY), each keyed to its required env var; the set is declared once and consumed exhaustively, so a provider cannot be half-registered - Two aggregators, one shape —
openrouter:andvercel:both front many vendors asprovider:vendor/model, which is what makes a cross-gateway failover chain possible: the same frontier model behind two independent intermediaries, each with its own circuit breaker - Gateway slugs are NOT a prefix swap — the
vendor/modelFORM matches but the vendor slugs differ for OSS models (mistralai/↔mistral/,z-ai/↔zai/,qwen/↔alibaba/); onlyanthropic/,openai/,google/andnvidia/are identical. A twin is two independentmodels.jsonentries — verify againstGET https://ai-gateway.vercel.sh/v1/models(open, no auth) - Price the twins identically — a bare-model cost lookup resolves duplicate model tails by declaration order, so twins with different rates can bill one gateway at the other’s price; an entry with no
pricingblock silently gets a single retry and no cost at all - Observability hooks —
executedModelIdafter each settle,onFallbackandonRetrywired intomodel.rerouted/runtime.retrystream events
- Three resolution modes —
- Adaptive Rate Limiting & Circuit Breaking — every model call runs through a per-step resilience wrapper before it reaches the provider
- Distributed circuit breaker — Redis-backed with atomic Lua scripts, so when one process detects a provider is down, bot, worker, pipelines and MCP all bypass it during the cooldown;
openUntilaccepts an explicit provider reset time and never shortens an existing window - Per-model rate limits —
rateLimit.rpmfrommodels.jsonis enforced proactively via the sharedRateLimiterPortunder categoryllm:{provider}:{model}(a Redis limiter in production, so the budget is deployment-wide) - Pricing-driven retry budget — 3 retries under $3/1M combined, 2 under $10, 1 at or above $10 or when pricing is unknown
- Per-attempt timeout, three tiers — call site (
ModelOptions.timeoutMs) → the model’s owntimeoutMsinmodels.json→ the 15 s engine default, resolved per chain STEP so a fallback model is measured against its own latency; background callers like the memory-ops worker take a long timeout and a single attempt instead of holding a serialized queue - The abandoned generation is cancelled — the deadline fires a per-attempt
AbortSignal, joined to the caller’s own cancellation and forwarded to the provider request, so a timed-out generation stops instead of streaming on unread and billed (once per timeout, plus once per retry) - Cancelling ends the waiting too —
/stop, a closed chat or an abandoned planning session also cuts short the pauses a call takes on its own (the proactive rate-limit hold, up to 60 s before a request even exists, and the retry backoff), and stops the chain where it stands instead of spending the remaining providers on an answer nobody is waiting for
- Distributed circuit breaker — Redis-backed with atomic Lua scripts, so when one process detects a provider is down, bot, worker, pipelines and MCP all bypass it during the cooldown;
- Routers for CLI Agents — reach an aggregator’s whole catalog through any CLI harness (
claude:openrouter:x-ai/grok-4.1,codex:vercel:openai/gpt-5.3-codex), with the router a closed set inMODEL_ROUTERS(openrouter,vercel) and the whole triple stored in the singlemodel_refcolumn- Endpoints are brain-tunable — each router’s Anthropic-protocol and OpenAI-protocol roots come from env vars with engine defaults (
OPENROUTER_*_BASE_URL,AI_GATEWAY_*_BASE_URL), so a router can be repointed at your own proxy without an engine release - A missing router key degrades, it does not crash — a
cliChainsstep whose router key is unset is dropped when the chain expands,omnislash doctorfails the role that depends on it, and setting a model ref to such a step is refused at edit time naming the exact variable - Routed turns do not inherit your direct keys — a routed Claude turn blanks
ANTHROPIC_API_KEYfor its subprocess and a routed Codex turn replacesOPENAI_API_KEYwith the router credential, so a key you set for something else can neither hijack routed traffic nor leak to the aggregator
- Endpoints are brain-tunable — each router’s Anthropic-protocol and OpenAI-protocol roots come from env vars with engine defaults (
- Usage & Cost Accounting — usage is derived from the spend ledger (
usage_records, below), never from a denormalized column, so it survives/clear, a compaction and the agent’s reap- Per-model segments — one segment per model/provider combo carrying
inputTokens,outputTokens,cacheReadInputTokens,cacheCreationInputTokens,reasoningTokens,costUsd,provider, optionalrouter,webSearchRequests - Billing type —
metered(real money) vssubscription(phantom cost) with the plan recorded (max,pro,chatgpt:plus, …); the two are reported apart and never summed. A CLI login’s subscription stayssubscriptionafter its session expires —meteredmeans a paid key or a router, never a dead login - A Claude turn costs its own calls — each turn records the API calls it made (main thread and harness subagents), priced from
constitution/models.json(ananthropic:<id>entry withpricing) or else the engine’s Anthropic list prices, never the Claude CLI’s running total for the resumed session; the CLI’s own compaction summary and helper calls are not counted; doctorModel pricesnames a model neither prices - CLI billing keys on the OAuth session, not the access token — a run that starts mid-refresh is still billed to the subscription, from one shared credential parser
- Cost math —
calculateSegmentCostbills cache reads at the cacheRead rate with a fallback to the input price, bills the reported 1h share of cache writes atcacheCreation1h(falling back tocacheCreation) — Claude CLI and Anthropic API turns report the 1h / 5m split — suffix-matches unknown model ids, and returns undefined for image-priced models - Context snapshot vs cost sum —
usage.contextis the LAST step’s prompt-side tokens (the turn’s real context size); the segment sum is a cost quantity ≈ steps × context and must never be shown as context;usage.firstCallis the FIRST step’s (on a fresh session, the whole prefix as the model received it) - Per-harness usage adaptation — Claude reports per-step
BetaUsagededuped by message id with a cache-creation TTL split and a context window; Codex is turn-total spend (cached_input_tokenspre-deducted,reasoning_output_tokenspassed through) with the context snapshot read from the thread’s rollout file (its newesttoken_count, compactions the turn made emitted as compact events); OpenCode synthesizes both from per-step accumulation - Lifetime vs last-run — an agent’s lifetime usage and its
/statsturn counters sum every priced turn it ever ran; “what did the last run cost” is its newest finished turn on the ledger; a workflow task’s usage is every turn its agent ran while the task was running (a retry and a recovery turn included); a run’s cost is every turn that ran inside it — its tasks’, their sub-agents’ and a turn that finished after a cancel — so it can exceed what its task rows add up to - Spend answers for agents (
usage_*, capabilityusage:read) — the ledger read back as five tools, the same answers the Cost page draws:usage_summary(totals vs the window before, month to date with its projection, what caching saved),usage_breakdown(spend grouped by up to five dims — role, entity with its feature, agent, conversation, execution, kind of agent, requester, row kind, run status, eval outcome, model, provider, router, plan, billing — top-N plusother),usage_trend(per hour / day / week / month, zero-filled, with the window before and a split by any dim; a window of more than 1000 buckets is refused with the finest bucket that fits),usage_heatmap(hour × weekday, a dense grid) andusage_tools(calls per tool name — which tools a role really uses).usage_breakdownalso groups bytrigger(what started the run:cron,event,manual,agent,parent,chat— a turn outside any run ischat), and a row grouped by a run dim (entity, feature, execution, trigger) says how many of its runs succeeded and the cost per succeeded run;usage_summaryanswers the window’scostPerSuccessUsd;usage_trendtakesunit: "per_run"(itssplitrows per run too) and marks its spike buckets (markers);usage_summaryandusage_heatmaptake afiltertoo. The window is the one shape every period read takes —window(1h,24h,today,yesterday,7d,30d,90d,mtd,all) or wall-clockfrom/to(YYYY-MM-DDTHH:mm) with an optionaltz, converted in the brain’s timezone (ortz) by the database; a filter narrows to a feature, an entity, a role, an execution (a workflow run’s older turns included, through its tasks’ windows), a model…; each answer says when retention reaped part of its window (coverage) - Execution cost — every run, pipeline invocation, heartbeat tick and hook fire carries
cost_usd: each priced turn that runs inside it (sub-agents included) adds its cost the moment it finalizes, even after the execution ended, and a replayed finalize adds nothing;nullmeans nothing in it was priced, never$0. The evalcost_usdmetric reads the same ledger - Spend ledger that outlives the agent — every priced turn segment (its terminal, or the HITL pause that split it) is also one row of
omnislash.usage_records: typed rollups (metered vs subscription cost, input / output / cache-read / cache-write / reasoning tokens, web searches, the dominant model) computed once from its segments, the segments themselves, calls per tool name, and who it was for — the role it ran as, the requester, the surface, the execution it ran inside (the tick, fire or run — a sub-agent’s spend counts toward its parent’s execution and entity), the scenario / heartbeat / hook it served, the agent and its kind. No foreign keys, so an agent’s reap (which takes its transcript) and a compaction (which deletes theturn.completedrows it folds) leave the spend in place; written once per segment by the agent runtime’s finalize, so a replayed finalize counts nothing twice. Keptusage.retentionDaysinconstitution/MANIFEST.yaml(default 365,0= forever), reaped by the agent-retention sweep; read it with theusage_*tools (below) ordb_query(omnislash.usage_records). History: the migration that creates it prices every turn the transcript still holds, and pipelines re-checks the last day at boot and every 6 h (the turns the previous version priced during a deploy, and a ledger write that failed — healed within that day, lost after it) - External spend from a pipeline —
recordSpend(ctx, { service, costUsd, billing?, units? })(pipeline SDK) books money a pipeline paid outside any agent turn — a metered API it called itself — as akind: 'external'ledger row attributed to the invocation (execution, scenario, requester), so the run’scost_usdand every spend reader count it beside agent turns; a journaled step whose row id derives from the invocation’s deterministic random, so a replay or a retried write records it once (pipelines.md)
- Per-model segments — one segment per model/provider combo carrying
- Money budgets with a hard stop — USD ceilings on METERED spend (
cost_usd_metered; subscription never counts), checked at every agent turn’s admission; a spent budget refuses the turn before any model call and asks a person on a card — budgets.md- Three scopes, three windows —
brain(day/month),scenario(run/day/month, sub-agents included),role(day/month, every agent of the role together — a role in a switched-off feature is under the MANIFEST default); calendar windows inlocale.timezone - Seam — engine every limit
null→ MANIFESTbudget.{brain,scenario,role}→ a scenario’s or role’sbudget:frontmatter, folded per window;nulloverrides the default below (resolveBudgetLimits,@omnislash-ai/config/spend-budget) - One gate — the agent VO’s
prepare-paramssums every applicable budget in one ledger query (readBudgetWindows); a refused turn endsfailed,errorCategory: 'budget_refused'(BUDGET_REFUSED— recognised by the category, never the message; distinct frombudget_exceeded, a CLI turn that ran past its owncli.maxBudgetUsd), executionerror_classbudget_refused - Consumers — a workflow task refusal parks the run
awaiting_retrykeeping completed tasks;safeRunAgentreturns the failed result to pipeline code; chat, heartbeat and hook turns end failed and the next one re-asks - Incident card — HITL
approval, producerbudget, one per(scope, window, windowKey)to the default HITL chat, expiring at aday/monthwindow’s end; +50% / ×2 or a replied amount raise THAT window only (omnislash.budget_raises); Cancel keeps it stopped (a turn an owner waits on — chat, dashboard or MCP — asks again); human responders only - Writes split by who may lift a ceiling —
budget_update(config:write) lowers or adds a limit on MANIFEST or one scenario / role and refuses a raise or removal;budget_widen(budget:widen, restricted, a person approves) is the 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, with no agent turn needed.manifest_updaterefuses thebudgetsection and therole_*writes abudgetkey. The dashboard binds all three budget_status(runtime:read, opbudget.status) — every limited scope with its spend, effective limit, raises and the card that speaks for it (pending/cancelled)- Loop cannot move a ceiling —
budgetis a measurement block: a loop proposal touching it is refused, andloop.budget(the loop’s own per-run allowance) stays a separate ceiling
- Three scopes, three windows —
Agent Isolation & Execution Context
- RunIn Execution Ladder — one per-agent execution-context vocabulary,
live | clone | sandbox, monotonic (live ⊂ clone (+git) ⊂ sandbox (+bwrap)), with the same name on every layerlive(default) — the agent works directly in the live brain checkout and thebrain-syncdaemon commits and pushes its writes to the G-set; nogit pushneededclone— its owngit clone --sharedwith a pushable remote and no jail, forced regardless ofisolation.enabled; this is what lets a live-brain agent delegate a durable edit of a cold file (CLAUDE.md,omnislash.config.ts,tools/,db/) to a sub-agent that commits and opens a PRsandbox— the same clone plus a bubblewrap jail; persisting it requires project-levelisolation.enabled(a hard write-gate), and a purely per-dispatchsandboxon an isolation-off brain degrades toclonewith a warning- Three declaration levels —
AgentSpec.runInin*.heartbeat.md/*.hook.mdfrontmatter (the only durable place for row-per-fire agents), the persistedagents.run_inrow default, and a per-dispatchAgentRunOptions.runInoverride for one turn - Who can set it — the Telegram creation wizard and the dashboard’s New session dialog both render all three rungs through one
resolveRunInAvailability()(available rungs enabled, unavailable ones explained — the dashboard throughagent.create’s dry run), andagent_create/agent_task/agent_update/agent_call/agent_enqueuetake the same vocabulary - Doctor check
Agent runIn— reads the same oracle the create UI does:failforsandboxdeclared on a brain with isolation off (a row-per-fire tick would never mint its row at all),warnfor silent degradations (clone/sandboxwith no baseline, dev-gatedsandbox, an inertrunInon a scenario block) - Degrade, never throw — a forced
clone/sandboxwith no baseline falls back to live rather than failing the run
- Per-Scope Sandbox Checkouts —
git clone --sharedfrom a shared baseline gives each run or agent its own full checkout: own.git/index,refs/,HEAD, stash and working tree, with objects shared through git alternates- Two scopes —
run(a workflow run, always cloned when a baseline exists, so parallel runs and PR-finalizer pushes get a private branch) andagent(standalone chat/dev, cloned per therun_inladder), both through oneresolveSandboxRepoPath - The owning run owns the turn’s cwd — a turn carrying the run’s
auth.runIdattaches to that run’s checkout (attachRunCheckout, attach-only — the run’s setup is the sole creator); every other turn resolves the agent’s own context, so neither side’s WIP leaks into the other. Mode-independent: an API turn’s file tools are rooted at the same attached tree - Path derived, never stored —
agents.repoholds a repo name (two engine-internal writers still pass a path;packages/infra/CLAUDE.mdscopes them), so the path is derived per dispatch: only the worker needs$RUNS_ROOT, no cross-pod path-equality contract, agent creation stays a single INSERT - Truthful prompt — the resolver returns the branch the checkout is really on, so the injected git-awareness block either says “the daemon syncs your writes” (live brain) or names the actual branch to
commit && push; a non-git path renders no block at all. - A clone knows its own age — the
## Environmentsection of aclone/sandboxrun carries aCheckout:line naming the baseline commit the tree was cut from (sha + committer date) and the instant brain-sync last fetched the baseline; “unknown” is stated, never omitted, when the baseline carries no fetch stamp. All three facts are captured once at clone time and never move for the checkout’s life (the agent’s commits moveHEAD, notorigin/HEAD; the fetch stamp is copied into the clone’s.git/), so the environment section’s hash stays put and it is not re-sent per turn. The fetch instant is what tells a quiet window from a stalled daemon — a tip date alone cannot - Resume reuses the tree — an existing checkout is reused whatever branch the work moved it to (detached HEAD included, reported as no branch); wiped and re-cloned only when the directory is not a git checkout at all, or when the caller mints a fresh branch per attempt and asks for it (
onExisting: 'recreate'— the cycle’s revert, where an existing tree is the crashed attempt’s wreckage, not its WIP) - Cleanup waits for reap — a checkout is removed only after the agent subprocess is confirmed dead (terminal row after SIGKILL + stream drain), so a live delete can never rip a clone out from under an in-flight
git push - Pinned to a commit — a workflow run carrying
content_sha(set only by the loop’s pre-merge check) gets its checkout detached at that commit — fetched from origin when the baseline has not seen it — and every turn attached to it reads the scenario, blueprint, role (body and frontmatter — model, grant), prompt templates and skills from that checkout while everything that is not content (judge, memory, config, grants) stays live; such a run is a replay that asks nobody and has no side effects: its input is validated against the blueprint at that commit (no pre-run input card), its task agents ask no questions, a gated capability fails it, scoped hooks and the PR finalizer are skipped, its agents hold no git credential, no remediation agent is spawned for it and it cannot be retried; it is loop-launched (AuthContext.loopLaunched, derived from itsloopcreator and inherited by every sub-agent, child pipeline and MCP session): every engine tool that writes, launches or sends runs dry, and the run page marks it replay · dry-run - Sweepers — a race-tolerant 15-minute filesystem sweep with a DB re-check before every
rm -rf(7-day TTL, ENOENT-tolerant, also collecting orphan and loop checkouts), plus the retention sweep’s synchronous per-agent cleanup
- Two scopes —
- Claude builtins follow the grant — a Claude turn is handed only the harness built-ins its capabilities name (
HARNESS_BUILTINS: read, plan andToolSearchalways;file:write→ edits,engineering:execute→Bashand its background control,research:read→ web,harness:subagent→Agent, the harness’s own in-turn subagents — not engine agents, which areagent_taskunderagent:execute), as an allowlist, confined or not — the capabilities being the role’s plus its live grants’, so an approvedengineering:executehands a shell to the next step;Cron*and other runtime-foreign built-ins are off for every role, and a renamed built-in on a newer CLI is warned at session start - Per-turn path confinement —
AgentRunOptions.confinementcarries three repo-relative glob lists (editable/denied/unreadable) that each CLI harness compiles, with the agent’s grant (nofile:write⇒ nothing editable), into its native permission surface; never persisted- One glob dialect —
@omnislash-ai/types/path-globmatches every repo-relative path rule in the engine:**across segments,*/?within one, a trailing/for a directory, a bare path for an exact match; no negation, braces or character classes
- One glob dialect —
- Bubblewrap Jail — a per-subprocess
bwrapwrapper that lives exactly from spawn to exit, giving mount / PID / IPC namespaces and--die-with-parent- Same absolute paths inside and out — the run checkout is bound at
/app/brain, baseline objects at their own path (so alternates resolve), so every absolute path in a tool call works transparently $ARTIFACTS_ROOTis a sibling, not a subtree — engine-runtime artifacts are bound RW at the same absolute path outside/app/brain, because a checkout bind over them made every task withoutputArtifactsfail withMissing output artifacts- Live skills bound read-only as a sibling — the catalogue is rendered outside the jail from the live tree and the same path is bound in, so the agent never gets
ENOENTon a path its own system prompt named, andgit statusin the checkout stays clean - Session stores bound RW —
~/.claude(withCLAUDE_CONFIG_DIR),~/.codex, and OpenCode’s four XDG directories, because pause ≡ process death and every resume spawns a fresh subprocess that must find its session file - Per-harness shims — one shim script per harness (
CliWrapperKind = Exclude<CliHarness,'fake'>) with a shared sourced body, baked into the image; adding a harness breaks the type until its shim exists, so a new harness cannot silently become unsandboxed - Activated by cwd, not a global flag — the isolation env is stamped only when the agent’s repo is under
$RUNS_ROOT, so planners, hooks, recovery andliveagents pass straight through the shim - Container prerequisites —
apparmor=unconfined+seccomp=unconfined+systempaths=unconfinedare all required (the third for the fresh/procmount), andomnislash doctorprobes with the shim’s real flag combination rather than a weaker smoke test that gave false greens - Threat model is write isolation, not read isolation — the jail prevents accidental cross-run damage between trusted agents; the host filesystem stays readable and the network stays open by design
- Same absolute paths inside and out — the run checkout is bound at
- Brain Baseline — a dedicated
brain_baselinevolume holds the full brain clone that every sandbox alternates against, with exactly one writer- Single writer — the
brain-syncdaemon bootstraps it (git clone $BRAIN_GIT_REMOTE) and refreshes it in its steady-state loop; workers mount it read-only, so a clone-path race between replicas is structurally impossible - Fetch plus local fast-forward — the refresh moves
refs/heads/<default>ontorefs/remotes/origin/<default>as well, becausegit clone --localreads local branches and a fetch-only baseline pinned every sandbox to the bootstrap commit forever git gcforbidden —gc.auto=0is set unconditionally on every bootstrap, since unreachable objects may be referenced by live clones through alternates; disk reclamation is a full rebuild in a maintenance window- Fetch stamp — every clean
fetchBaseline(and the bootstrap clone) writes.git/omnislash-baseline-fetched-atin the baseline; a sandbox copies it at clone time and reports it in its prompt, so a run can see that the daemon has been silent rather than infer it from a file that “looks truncated”
- Single writer — the
- Git Write-Back — commits leave a sandbox over the network, never through a shared filesystem
- Push to the real origin — the per-clone
originis rewritten to the baseline’s upstream (a local clone would otherwise point at the read-only baseline path), so an agent’sgit push origin <branch>lands on GitHub; re-applied on every prepare, not only after the clone, so a baseline whose remote changed propagates into long-lived agent checkouts that already exist - Auth is two lanes, chosen per spawn — a GitHub checkout is served by an hour-long installation token delivered through the environment (
GH_TOKENforgh,GIT_CONFIG_*carryinghttp.<remote>.extraHeaderforgit); everything the App cannot serve (non-GitHub remotes, ssh URLs — D7) falls back to the host key.~/.gitconfigis bound read-only into the container and the jail either way,~/.sshonly into the container — the jail masks it with a tmpfs unless that spawn picked the ssh lane - What the mask buys — not isolation from engine credentials (agents still hold
DATABASE_URLandCONNECTIONS_TOKEN_ENC_KEYby design), but that a non-expiring, account-wide write key stops sitting where a prompt-injected agent could copy it out, replaced by a one-hour token scoped to one repo - Committer identity enforced at the boundary —
assertGitIdentity(brain-sync’s boot, after the App-derived default has had its chance) fails startup when neither tier produced a pair, andmirrorAuthorToCommittercopies author into committer since git links neither by default - Branch naming helpers — sanitized branch fragments and collision-free
feature/…names for agent-opened branches gh-backed PR helpers — read a PR’s merge state and changed files by URL (no checkout needed) and close a PR with an explanatory comment- Brain history —
git_history(runtime:read) lists brain commits newest first — a feature, an entity’s files or paths, bounded bysince/until— each taggedperson/evolution/brain-sync/engine(filtertags) with its squash-merge PR; paged bycursor - One commit whole —
git_get_commit—git.get_commit(runtime:read) returns one brain commit’s author, tag, PR, full message and per-file change with line counts and unified diff, scoped likegit_history; binary, large or over-budget diffs are named only
- Push to the real origin — the per-clone
- Deploy-Owned GitHub App — the engine can hold its own git identity instead of borrowing the host’s ambient SSH key: a GitHub App is registered through GitHub’s App Manifest flow — started by an agent’s
git_request_appcard or the dashboard’s Connect GitHub (git.authorize), both handing out the same link — and the App id, slug, client secret, and AES-GCM-sealed private key land inomnislash.git_apps(one row per provider). The PEM never leaves the engine and the tRPC surface exposes onlyappId/slug/htmlUrl; the manifest’s permission set is a brain-widenable floor (git.appPermissions) and its installability isgit.appPublic(defaulttrue, so one App can hold installations on several accounts) — both inpackages/connections/docs/connections.md§git_apps- Agent path —
git_overview(runtime:read) reads the App, its installations and, for the brain repo and every attached repo, which lane authenticates it (the App’s token or the ambientGH_TOKEN) and why (ssh-remote,no-app,account-not-installed,repo-not-granted, …) — or laneunknownwhen the installations could not be read in time (read again before asking for an install).git_request_app(integration:execute) posts ONE card to the default HITL chat whose link takes a person through GitHub’s registration screen and straight on to its install screen (step: 'register'), or to the install screen alone (step: 'install') —registeris refused while the registered App works and is installed somewhere, since a replacement drops every installation; the card goes to a person at once (never held for a parent agent), and no agent-facing reading of it carries the link; the card closes itself when GitHub confirms the installation and the agent wakes with the account and repository selection. Every App remedy — credential rows, the merge gate, doctor hints,omnislash git *— names this tool first and the dashboard’s Connections › Repos (/connections/repos) second - Installations — a person installs the App on an account or org from GitHub’s own install screen, which is the repository picker — reached from a registration (which always continues there), an agent’s
installcard, or the dashboard’s Add account (git.authorize({ step: 'install' }));GET /github/install-callbackwritesomnislash.git_installationsfrom an App-JWTGET /app/installations/{id}read rather than from the redirect query, because a spoofedinstallation_idis expected there. One App, N accounts — a second org is a second row - Installation-token mint —
getInstallationTokensigns a 10-minute App JWT with the PEM and mints an hour-long token scoped to bare repository names, cached in Redis undergit:token:<installation>:<scope>until 5 minutes before expiry and minted under a per-key lock, so N parallel agents cause one GitHub call; the token is handed out as a{ username, password }credential pair and never spliced into a remote URL - Delivery to git,
ghand agents — engine-side git (brain-sync’s brain-repo and attached-repo clone/pull/push, the repo-sync worker, the loop’sghcalls and revert push) and every sandboxed CLI agent resolve the credential from their own remote or checkoutorigin; it reaches git throughGIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_*(appended after any inherited slots, so a compose-levelsafe.directory=*survives) andghthroughGH_TOKEN. A checkout the App cannot serve yields no credential, which is itself the signal that unmasks~/.sshin the jail.GH_TOKENset by hand is the declared fallback lane for a deploy with no App — it needs the App’s own permission set (the list: operations.md § Infisical,GH_TOKENrow), which itsCredentialsrow restates and the doctor’s GitHub merge gate probes forCommit statuses;omnislash doctor→Git identitysays which lane is live and whetherpullRequest: truecan work at all; →Brain git remotesays the same forBRAIN_GIT_REMOTEspecifically - Event receiver — the manifest subscribes the App to every PR, review, review comment, review thread, conversation comment, check run/suite, status, push and issue event (plus the
installation*events GitHub sends every App) and pointshook_attributesatPOST /github/webhookon the webhooks origin. The route is mounted unconditionally (a server-to-server receiver needs neither public origin — GitHub already holds the URL) and gated on nothing but the App’s sealedwebhook_secret:X-Hub-Signature-256is verified as HMAC-SHA256 over the RAW body withtimingSafeEqual, and no secret on record means 401 rather than a trusted payload. Each delivery is recorded in the inbound sink as a typedgithub:<event>.<action>event (flat payload, sender-written text fenced, resource keysgithub:pr:<owner>/<repo>#<n>and friends, a fork PR’s checks joined to the PR by head SHA, a redelivery deduplicated by its delivery id) and fires hooks like any event — integrations.md § GitHub events. The loop’s merge signal (closedends its merge wait,auto_merge_disabledturns an armed wait into a human one) and the installation list (suspend/deleted/created) are consumers of the same durable route job - Subscription drift is told, not guessed — no GitHub API changes an existing App’s events or permissions, so an App registered with fewer receives fewer until a person edits it. Doctor GitHub App subscription (Infrastructure) compares the App and every installation against the engine’s event set and the resolved permissions (
checks: read+issues: readjoin the floor;git.appPermissionsraises levels, the stronger wins) and the webhook URL against this deploy’s, and its hint is the message to forward: the App’s settings page (org- or user-owned as GitHub reports), the levels, the checkboxes by their GitHub labels, then each installation’s page to accept. The worker runs the same check at boot and daily and sends it to the status chat once per distinct drift. What the App CAN change itself — its webhook URL and secret — the worker sets without anyone (at boot and daily, before that check): an App with no secret on the deploy’s side (registered before the manifest carried a webhook, or imported without one) gets a fresh one on both sides, a missing URL is pointed at this deploy’s receiver, and a URL pointing elsewhere is left to its owner - Payload retention — a handled GitHub event’s payload is emptied after
github.payloadRetentionDays(MANIFEST, engine 30 days); the row, its delivery id and its resource keys stay - Missed deliveries recovered from GitHub’s own log — GitHub never retries a failed delivery; every 5 minutes and at boot the worker lists the App’s delivery log back to where it last read (GitHub keeps three days), takes every delivery with no successful attempt (grouped by delivery id — a failed attempt a redelivery then got through is not a candidate) and no row in the inbound sink, and pulls its payload straight from GitHub (App JWT) into the same ingest the receiver uses. Direct, not a redelivery: a redelivery goes back through the ingress that just failed — a rotated secret 401s forever. Doctor GitHub webhook delivery warns when deliveries had to be recovered in the last day (with GitHub’s account of the newest failed attempt:
401,413, a timeout) when the reconciler has stopped running — quoting the newest failed pass’s own error, so the reason reaches an agent without logs — and when GitHub listed attempts in a shape the engine does not read (skipped one by one, never a failed pass). An App with no webhook has no log at all: that is a recorded reason, not a failing job. Cadence is an engine constant, not a seam - Derived PR signals —
github:checks.settled,github:pull_request.mergeability— what GitHub never sends: “every check on this head reached an outcome” (one signal per head and outcome set, once ALL of it is terminal; a re-run ending the same way is a new one; a head with no checks at all settlesnoneafter a 10-minute grace) and “this PR began / stopped conflicting” (on a transition only — a push to the base that leaves it mergeable wakes nobody). One GraphQL probe per PR reads both (check rollup with per-PRisRequired, mergeability), debounced 30 s after CI activity or a push to the PR, every 5 minutes for watched PRs and on pushes to their repository; recorded through the inbound sink with deterministic keys, so hooks and watches hear them like deliveries — integrations.md § GitHub events
- Agent path —
- Multi-Repository Operations — agents work across multiple code repos beyond the brain, with scenario-level repo targeting and clone-on-attach
- Attached repos are rows, not env —
omnislash git attach <owner/repo>, or the Attach button on the dashboard’s Connections › Repos (/connections/repos), writes acode_reposrow (slug, remote, installation, optional default branch, sync status); the repo map is read live on every call, so a repo attached at runtime shows up without a restart. D6: attachment is owner data — there is deliberately noreposfield inomnislash.config.ts - Path resolution — one row works in prod and in local dev:
/repos/<slug>if cloned, else a sibling checkout next to the brain root, else/repos/<slug>as the fallback the doctor warns on. The brain checkout is always the first entry of the map - Per-repo isolation — a workflow targeting an attached repo gets a per-run sandbox cloned from
/repos/<slug>, whoseoriginis the real upstream, so write-back works exactly as it does for the brain - Skills stay out of the tree — skills are delivered to an attached-repo agent as a system-prompt catalogue under the absolute skills root, so
git statusin a third-party checkout is never dirtied - Doctor checks — every resolved repo path is validated on disk (missing = warn), and every scenario
agent.repomust name an attached slug or an absolute path (unattached = fail); a DB the doctor cannot reach downgrades both to warn
- Attached repos are rows, not env —
- Inbound Repo Sync — a repeatable worker keeps
/repos/<slug>current, the read-direction counterpart to the write-direction brain-sync daemon- Sweep — every
REPO_SYNC_INTERVAL_MS(default 5 min, plus a startup kick), always registered, over thecode_reposrows; a row whose checkout is missing is cloned first, each repo under a composed abort signal (shutdown or a per-repo operation timeout) - Self-heal a wedged checkout —
rebase --quit(clears even a corrupted rebase that--abortcannot), hard reset onto the freshly fetched ref, stash clear — so an interrupted sweep cannot freeze a repo forever - Snapshot, don’t stash — dirty tracked edits are committed as a
repo-sync: snapshot local editscommit (never pushed) and rebased forward, because a conflicted stash pop leaves a half-applied tree the next sweep cannot heal - Supervised conflict episodes — a rebase conflict aborts cleanly, warns the status chat once (dedup-keyed per repo), and spawns a non-interactive
system:git-conflict-resolveragainst that repo with a structured report schema, while the sweep holds off the repo for as long as the resolver is busy - Episode lifecycle — a resolver past a 15-minute deadline is re-dispatched with
restart: true, escalated at attempt 3 and every 10 after, and closed with a ✅ report only when a clean pull verifies it; in-flight resolvers are adopted after a worker restart
- Sweep — every
- Isolation Configuration —
ProjectConfig.isolation{ enabled, kind: 'bwrap' }(default off), plusBRAIN_BASELINE/RUNS_ROOT/ARTIFACTS_ROOTenv paths that default to temp dirs in dev- Mode matrix — in prod a baseline always exists so run-scope always clones; in
startit depends on a configured remote; indevthere is no baseline and every rung degrades to the live host tree BRAIN_GIT_REMOTEis boot-validated — required in production and whenever isolation is enabled, so a gitless-agent deployment fails fast instead of silently- Persistence horizons — per-run clones,
~/.claude/~/.codexsession stores and the artifacts root live in named volumes or host binds so a HITL pause survives a container restart; the jail’s/tmpis a tmpfs and deliberately does not
- Mode matrix — in prod a baseline always exists so run-scope always clones; in
- Why not
git worktree— worktrees were removed outright: they share.git/objects,refs, stash, gc and prune, so they closed one of eleven real conflict classes while creating the illusion of isolation
Chat & Conversational Surface
- Telegram Bot — Grammy-based bot with commands, wizard flows, inline keyboards and live cards, running N personalities over one chat-core service graph
- Polling or webhook — N polling runners restarted on unexpected stop, or one server multiplexing N secret webhook paths with TLS behind Caddy; either way
allowed_updatesis always an explicit list (Telegram persists a previously-set one) - Transport resilience — five API transformers, innermost first (a log of every 429 with its
retry_after,autoRetryfor 429s and 5xx,apiThrottlerfor rate limits, a log of every chat call slower than 5 s,scrubTokenErrorsto keep the bot token out of the error that escapes), an update-dedup high-water mark per bot id (so a restart never reprocesses), and per-chat sequentialization with an explicitOVERTAKE_PREFIXESopt-out for HITL / approval / live-card callbacks that must never queue behind a slow handler - Send resilience — an HTML → plain-text → file fallback chain that only walks for format-degradable 400s (a 429, a blocked bot or an unusable chat surfaces directly instead of tripling traffic), plus in-place self-heal for group→supergroup migration and for a deleted forum topic
- Error handling — Grammy transport errors classified for UI and Sentry, with harmless cases (bot blocked, “message is not modified”) swallowed
- Polling or webhook — N polling runners restarted on unexpected stop, or one server multiplexing N secret webhook paths with TLS behind Caddy; either way
- Chat Access Frame — one frame per bot answers four questions about every inbound event — which role serves the chat, who the person is there, when the bot answers, what they may run besides talking — data first (
constitution/access.yaml), code over it (bots[].policy); with nothing configured the bot serves only its owners — chat-access.md- Tiers ladder —
engine default → access.yaml bots.<bot>.default → bots.<bot>.dm | chats."tg:chat:<id>" → policy.chat(), folded field by field by one resolver (context/limitreplaced whole); keys are numericSubjectIds (tg:user:<id>,tg:chat:<id>), a@usernameis refused with the way out - The role belongs to the chat — everyone in a chat talks to one role with that role’s tools and memory; an owner’s DM runs
bots[].role,dm.roleserves everyone else who may DM the bot, and a group has NO role until its own rule names one (fail-closed: the owner’s role never leaks into a room).chat()never sees the sender, so the role cannot jump between people. Every chat agent row follows its chat’s role on each message; an owner’s own DM keeps its/modeltuning - A closed set of tiers —
owner(global: env owners inTELEGRAM_ALLOWED_IDS, numeric only, plusidentities: owner; every command, control buttons, HITL approvals),member(talks throughengage, runs the rule’scommands),stranger(no turn; lines reach the context block only undercontext.from: all),blocked(dropped, not even logged). Resolved owner → on-behalf →identities→rule.members(owners·listed·everyone·{ memberOf }checked through the platform) →sender(), which cannot mint an owner - A member’s turn is capped — the rule’s
capabilities(capability ids,'*'= no ceiling; engine default none, so a member talks but uses no tool until a rule names it;sender()may name one person’s) is the ceiling of a non-owner’s turn: role ∪ live grants ∩ ceiling, a'*'role becoming the ceiling. Tools, Claude builtins, the capabilities prompt module, an agent the turn spawns or hands work to, a prompt it schedules all stay under it;hitl_request_grantrefuses above it and points ataccess_widen; owners are never capped; a Secretary counterpart reads the bot’sdefault⊕dmceiling - One gate, before the log —
accessMiddlewaredecides before observe: blocked dropped, strangers refused (a DM gets the rule’sdecline), a member’s closed command refused privately, and the turn carries the sender’s tier and capability ceiling asauth.access. A message sent on behalf of a chat (an anonymous admin, a linked channel’s auto-forward, a post as a channel) is that chat’s: it gets the tieridentitiesgives the chat’stg:chat:<id>(list your own journal channel asowner), else whatmembersgives anyone unlisted — astrangerunderowners/listed, amemberundereveryone— no admin rights needed - Unknown groups ask —
my_chat_memberrecords every chat a bot sits in (bot_chats, plus each bot’sgetMe()health inbot_profiles); a group no rule serves followsunknownChats:ask(default — an owner card: Serve… → role → who may talk / Only me / Leave, answered in Telegram),ignoreorleave. A group upgraded to a supergroup keeps its rule, itsmemberOfreferences and any open card under the new id - Menus and refusals follow the rules — command menus are synced per scope (group defaults, each served group’s
commands, the full list for owners) on start and on everyaccess.yamlchange; a refusal is an ephemeral message only the person sees (an alert or their DM as fallback), never a public post - Per-member turn budget —
limit.turnsPerHour(default 30,0= none) counts the turns the bot actually takes, per member and chat; over it the bot stays silent and says so privately once an hour; owners and Secretary counterparts are never limited - Hot, validated, explained by the same code — the file is re-read on every event; a file naming an unknown bot, role, command or capability fails the boot, a later invalid edit is logged and the last valid version stays in force. The runtime gate, the
access_explaintool and the dashboard simulator all call one resolver (resolveChatAccess), so an explanation cannot drift from behavior - Business updates authorize on the connection — an owner connecting the bot is the grant (a non-owner’s connection is recorded but never served, checked on every message); a counterpart is a
memberof the counterpart’s routed role, outsideaccess.yaml
- Tiers ladder —
- Messenger-Agnostic Chat Core — five ports define the whole contract between the chat framework and a platform adapter, so the Telegram app only routes updates
- Ports —
MessengerPort(reply / edit / delete / sendFile, optionalsendVoice/sendSticker/setReaction/createThread/probeThread),StreamUIPort(status presentation, final answer delivery, typing presence),ChatStatePort(active agent + delivery cursor + binding reverse lookups),ChatAgentPort,MediaSourceResolver - Optional members are a capability probe — every decorator in the chain must delegate them and throw when the inner adapter lacks one, never silently no-op (the regression that made every
voice_sendfail) - Container lifecycle —
createChatServices()wires the whole graph with orderedstart()/stop()hooks (delivery recovery first, notification worker only after card handlers are subscribed, content hot-reload in every mode including production)
- Ports —
- Chat Command Surface — the
/commandsa chat user can run, most of them platform-agnostic in chat-core- Agent control —
/agents(panel, with ✏️ Edit / Chat / Last Output / Stop / Clear / Compact / Delete per agent),/chat(back to the bot’s own agent),/clear,/compact,/model,/settings,/steer,/stop,/context,/stats - Loop —
/idea <entity> <text>(post a hypothesis into an entity’s ideas bucket, with a Withdraw button while it is queued — see Evaluation & Self-Evolving) - Execution —
/run(run list, adhoc and repo plans, in-place execution),/plan(adhoc plan wizard),/p(Restate pipeline runner),/hb(recent heartbeat runs and detail) - Platform-native —
/start(menu + deep links),/restart(self-restart with a persisted ”⏳ Restarting…” message edited to ”✅ Restarted” on boot),/help(what the person may run where they are),/connect,/tg_auth,/tg_accounts,/tg_default,/tg_remove,/logs(owner-only recent logs, read from Loki across every service this deployment ships — requires the monitoring stack),/artifacts
- Agent control —
- Per-chat Configuration — per-chat active agent, agent override and model override, backed by an LRU cache over a DB-persisted
chat_staterow keyed by(chat_id, bot_id)- Chat agent bootstrap — each chat lazily resolves its own chat agent (one row per chat and bot, looked up by persona name), so
novain three chats is three independent agents; its role is the chat’s route — the chief in an owner’s DM with the chief’s bot, the counterpart’s secretary role in a business chat, a rule’s role in a group - Free-form model switching —
/modelshows the current ref, themodels.jsonentries and the ref formats with a Cancel button, then consumes the next message as the new ref, rejecting typos at parse with did-you-mean hints
- Chat agent bootstrap — each chat lazily resolves its own chat agent (one row per chat and bot, looked up by persona name), so
- Editing an Agent in Words — ✏️ Edit on an agent card lists every editable field with its current value and one or two example values, then takes a free-form sentence in any language and applies it
- One declared field set —
AGENT_EDITABLE_FIELDS(name,displayName,groupKey,role,modelRef,repo,runIn,capabilities,systemPrompt) is what the chat hint prints, what the parsing LLM chooses from, and what the dashboard form renders, so no surface can offer a field the applier would refuse - Model maps, code decides — the
value-role model only emits(field, value)string pairs against the brain’s real roles / repos / models; typing, list splitting, clearing rules and the closed-worldrunIncheck are a puretoAgentPatch, and every value is re-validated at apply time — a hallucinated repo ends as “Unknown repo: X. Available: …”, never as a write - Reports what moved, not what was asked — the confirmation lists the fields whose stored value actually changed (a restated value reads as “nothing changed”), plus anything the parser could not map, and a session reset when the CLI harness changed
- One-shot flow — a parse failure does not re-arm, so an armed edit never eats an unrelated chat message; Cancel retires the hint card in place
- One declared field set —
- Chat Settings —
/settingsshows the chat’s switches as one card of checkbox buttons; a press flips one and re-renders the card in place. Per (chat, bot), shared by the chat’s topics and threads; an owner’s press only; none in business chats- Voice replies (off by default) — every answer also arrives as a voice note right after its text: the engine synthesizes the answer’s speakable part (markdown dropped, code blocks and bare URLs left to the text) in the answering role’s voice (its
speech:block, else the brain’sspeech.chain), shows “recording a voice message” meanwhile, and sends it silently, threaded like the answer. The agent is not involved. Refused with the reason when no speech provider is configured; a failed synthesis drops that one voice note — see voice - Interrupt mode (off by default) — the dispatch mode below
- Voice replies (off by default) — every answer also arrives as a voice note right after its text: the engine synthesizes the answer’s speakable part (markdown dropped, code blocks and bare URLs left to the text) in the answering role’s voice (its
- Chat Dispatch Modes — a per-chat switch (
/settings→ Interrupt mode) decides what a message sent mid-turn doesqueue(default) — the message waits in the durable inbox and merges into the follow-up turn; the live status card is the queue’s acknowledgement, with previews and per-entry cancel buttons rather than a standalone “Queued.” message that would hang staleinterrupt— a new message preempts the in-progress turn (steer); the preempted turn’s message stays claimed and re-serves merged behind the interrupter- Mode-independent steering — a native reply to the live status card, or
/steer <text>, always preempts; bare/steerflushes the queued inbox now /stop— snapshots and clears the inbox first (so queued pokes cannot restart the agent), then cancels the live run, and replaces the status card with a stop record echoing what was dropped
- Live Turn Controls — the streaming status card is the control surface, carrying a derived control row recomputed at every transition
- Buttons — ⏹ Stop, ⚡ Run queue now, and one full-width ✕ cancel per queued entry labelled with that message’s preview
- Queue line — a live count plus previews, excluding the live expectation’s own entry (after a roll-over the head is being served, not waiting)
- ⏸ pause anchor — when the bound agent suspends on a HITL question the card says what the chat is waiting for, so a queue behind a missed question card does not look like a dead bot
- Durable actions, in-memory rendering — the buttons act on VO +
pending_inputsstate, so a post-restart orphan card still works; a card-identity guard makes a tap on a stale card act on nothing (never a Stop of a run it never belonged to) and retires its keyboard
- Chat Turn Lifecycle —
ChatTurnis the single owner of chat-side turn liveness, with liveness derived from a phase enum rather than stored flags- Dispatch identity — the chat pre-mints the mailbox entry id before opening the turn, and every stream event and terminal carries the ids the agent turn serves, so acceptance is one positive membership rule (
serves) rather than status, arrival order or an exclusion slot - What that rule drops by construction — a preempted turn’s trailing content, cancelled terminals any number of steers deep, a turn cancelled before its first delivered event, pre-restart leftovers on a fresh process, and programmatic turns on the same agent
- Transitions —
open(idempotent re-begin for a steer, with DM card reuse and group card re-threading),expectQueued(FIFO of queued follow-ups),dropQueued,resume(HITL continuation on a live turn only, safe to call twice; an idle turn declines so the turn’s answer is recovered from durable rows), the question-context handoff, the preempted-turn settlement,finalize,closewith roll-over,closeStopped,abandonon dispatch failure - Zombie-turn reconciliation — a live turn whose expected entry no terminal will ever announce is detected from an unserved terminal plus a durable mailbox check (past a 30 s mint grace) and torn down or rolled over, so a frozen card with a growing queue line cannot accumulate silently
- Single ordered channel — teardown rides only the agent’s serial stream consumer; the turn holds no raw pub/sub bell subscription, because that raced ahead of in-flight deltas and wiped turn state before the answer was read
- Dispatch identity — the chat pre-mints the mailbox entry id before opening the turn, and every stream event and terminal carries the ids the agent turn serves, so acceptance is one positive membership rule (
- Final-Answer Delivery — one answer per turn: the last non-empty assistant text block the turn wrote, across HITL pauses, delivered once at its terminal; earlier blocks are narration and never become messages
- Narration on the status card — assistant text deltas (like reasoning) feed the card’s thought slot while the turn runs, newest text winning, so the person watches the work without a message per block
- A new, notifying message — at completion the answer (plus attachments) is sent fresh, threaded under the trigger in groups, and only then is the status card deleted; edits do not notify, so the card is never edited into the answer
- Terminals —
completeddelivers the answer;faileddelivers it followed by the ⚠️ line and[🔄 Retry]under the usual audience rules;cancelled/interrupteddeliver nothing - Pauses — a HITL pause does not cut the answer: a resumed run that writes no text keeps the text written before the pause, so an approval mid-turn never swallows the answer
- Question context — the text written before a question tool call (or, with none, the agent’s last reasoning) is taken on the ordered stream at that call and delivered ahead of the question card, to the card’s destination (the owner’s DM for a Secretary-Mode chat); the card waits for it at most 10 s and is never withheld; recovery after a restart never sends it a second time
- Preempted turns — a steer or interrupt-mode message sets the running turn’s answer aside; that turn’s own terminal decides the owner: cancelled → delivered live under its own trigger, completed first → the durable settlement delivers it — one owner per answer
- Reroute restart — a
model.reroutedwith a next step clears the pending answer (deduped perturnId/from/to), because the fallback model writes its own - Why — the CLI harnesses already treat the final message as the turn’s answer and between-tool text as narration; delivering every block fought that contract and shipped duplicate answers and trailing “saved a note” messages that no prompt rule reliably prevented. OpenClaw-style bridges converged on the same final-only + progress-draft split
- Status Presenter — one status message per turn with a slot model, throttling and back-pressure
- Two slots — a thought slot (reasoning, rendered incrementally by a safe-prefix markdown renderer that holds back unclosed markers) and an action slot (tool calls, results, tasks, errors, retries, fallbacks, plan updates, warnings, progress)
- Single-writer funnel — one private issue path owns every
presentStatuscall and every message-id assignment, with catch-up flushes for concurrent updates and a drain-then-issue path for explicit content - Generation fence — bumped on acquire/release/detach/delete so a slow edit from a finished turn cannot resurrect a stale message id; a gone message clears the cached id and the next present sends fresh
- Throttling — a per-surface edit interval owned by the messenger adapter (Telegram: 1 s in a DM, 3 s in a group) with errors, retries, fallbacks and plan updates bypassing it
- Agent-Output Rendering — LLM text is treated as markdown intent and converted per surface at one chokepoint, never escaped into HTML
- markdown-it IR pipeline — a real parser (not regexes) converts markdown to Telegram-safe HTML, including code fences, inline code spans and tables rendered as HTML or
<pre>under a per-port width budget - Message splitting and chunking — HTML-aware splitting at the 4096-char limit (
<pre>-safe) plus markdown-aware chunking on paragraph / newline / sentence boundaries with fence safety - One producer for verbatim agent output —
renderAgentOutputemits platform-neutral markdown with an optional escaped label prefix and no truncation, locked by a class test; genuinely plain text (errors, ids, names) stays HTML-escaped
- markdown-it IR pipeline — a real parser (not regexes) converts markdown to Telegram-safe HTML, including code fences, inline code spans and tables rendered as HTML or
- Reaction Status Channel — the turn lifecycle stamps a reaction on the user’s own message: 👀 when the dispatch lands in the inbox (visible even while queued), then the terminal verdict — the 👀 retracted on success, 👾 on failure, cleared on cancel or interrupt (the message is already in the agent’s history)
- Success retracts rather than stamps — a 👍 per handled message would mark the chat unread and queue a reaction badge the user has to dismiss, so the happy path ends with no reaction at all; a brain that wants the stamp sets
reactions: { ok: '👍' } - Brain-overridable —
defineProject({ reactions: { seen?, ok?, failed? } }), validated at startup against the platform’s fixed reaction vocabulary;nullon any verdict means “clear, don’t stamp” - Presence-class traffic — sent through the un-throttled API and best-effort, so a platform without reactions simply no-ops
- Success retracts rather than stamps — a 👍 per handled message would mark the chat unread and queue a reaction badge the user has to dismiss, so the happy path ends with no reaction at all; a brain that wants the stamp sets
- Failed-Turn Retry — a failed turn’s ⚠️ bubble carries
[🔄 Retry], so an outage someone has since fixed (expired CLI credentials, a rate-limited provider) costs one tap instead of re-typing the message- Replayed from the timeline, not a side store — the turn’s input row is persisted before the run executes, so the exact text and attachments survive a turn that emitted nothing; there is no TTL and no “retry data expired”, and the button still works after a restart
- Verbatim replay — the stored assembled prompt is re-sent as-is (re-assembling would change what the agent is asked; that is what typing again is for)
- Proven target — the agent comes from the surface’s lane and the turn is then looked up inside that agent’s conversation, so a stale button can never replay at the wrong agent; the button retires on tap, which makes it single-use
- Only turns a human dispatched — a failed schedule/heartbeat gets its provenance header but no button: replaying it would re-send a note the agent wrote to itself as if the human said it, under the tapper’s chat auth instead of the scope its grants hang off
- Typing Presence — turn-scoped activity indicator: on at
openand at a HITLresume, off at full teardown and across a HITL pause (when the human is the one expected to type), with the platform owning the keepalive cadence - Chat Delivery Durability — a finished chat turn is guaranteed to deliver its answer, reconciled from the database rather than from the stream
- Durable per-chat cursor —
chat_state.delivered_event_idover the globalconversation_eventsid space, advanced by exactly one owner per (terminal × chat) and stamped to the high-water mark whenever a chat’s active agent changes (so a newly bound agent’s history is never owed) - Four writers, one core — the live completion path, the unserved-terminal settlement, the cross-surface mirror and the boot recovery all run the same reconciliation: each completed turn’s answer — its last assistant message, the same rule as the live path — re-sent as a fresh notifying message, a trailing failed turn given the live path’s failed-turn message — its answer so far plus the ⚠️ line (with the same
[🔄 Retry], on human-dispatched turns), cancelled/interrupted turns swept silently so a restart never resurrects a steered-away answer - Boot recovery — every chat with an active agent is reconciled before any subscription; a busy agent is armed (rebind) rather than settled, and re-read afterwards so a terminal that landed inside the subscribe-floor window is settled immediately instead of waiting for the next boot
- Unserved-terminal settlement — a chat-dispatched, final terminal observed on its origin chat settles that chat from durable rows without touching a live steered turn, which also delivers the steer-vs-completion race’s answer live
- At-least-once by construction — the cursor advances only after the send attempt, so a crash costs one duplicate, never a lost answer
- Durable per-chat cursor —
- Cross-Surface Mirroring — a conversation continued on the dashboard shows up in the bound Telegram chats — the user’s messages and each turn’s answer (its last assistant message, never the narration before it) — delivered silently (no push) so the user is not re-pinged
- Origin skip — the chat that originated the turn is served with a notification by the live path; every other bound chat gets the silent mirror
- Non-completed terminals sweep — a failed or cancelled turn’s rows advance the cursor without delivery, so they cannot resurface later as a stale prefix
- Live Status Cards — real-time, debounced cards in chat for workflow runs, planning sessions and pipeline runs
LiveCardMessenger— 1.5 s debounce, immediate render for terminal statuses that preempts a live debounce timer, terminal stickiness (a finished card can never revert to its live keyboard), fire-once finalize on every path, Redis + DB message-id storage, per-run surface caching, and a bounded dead-entity set- Workflow card — rebuilt from the run detail on every lifecycle event, with a status-derived keyboard (Plan / Stop / Pause / Resume / Result / Retry / Recover)
- Planning and pipeline cards — the same pattern with their own domain events, plus a single terminal-clamped status owner and a per-port character budget for pipelines
- HITL cards — human-blocking interactions are rendered by a separate notifier off the HITL event bus rather than through stream callbacks, with cross-channel finalize so a dashboard- or MCP-resolved card loses its stale buttons in Telegram too
- Recovery notice — one plain-text outcome message for a recovery that never spawned an agent to speak for itself, routed to the run’s surface or the status chat
- Engagement — the frame’s “when does the bot answer” for an admitted
member/owner: the rule’sengage(addressed·always·never), withpolicy.engage()over it — one function from normalized signals to a verdict, resolved once per message and cached — engagement.md- Signals — surface,
selfandroster(persona names),scope,chat(tg:chat:<id>),chatTitle,sender(tg:user:<id>, username, name),tier, the resolvedrule,memberStatus(),addressed,automaticForward,senderChat,text,media/replyToMediakinds,threadRootidentity,messageId, andonBehalffor business messages; every field is a primitive or an opaque id, so no platform type ever reaches a brain policy - Verdict —
undefined(defer to the rule’sengage), a boolean shorthand, or a decision carryingsteer(an instruction injected into the turn),toolContext(a deterministic engine-opaque bag carried verbatim to every tool call of that turn, never through the model),agent: 'chat' | 'thread',context.thread(row cap or off),context.peers(other bots’ replies shown or hidden) andtranscribe.autoPost - Per-chat knobs in the rule — the chat rule’s
agent/context(threadrows,peers,from) apply to every turn the bot engages there, the rule’s own gate included, a decision’s own value winning field by field;bots[].engagement/bots[].engagedare retired and fail the load with their new home - Engine defaults per scope — DM always engages, a served group only when addressed with a 10-row context block of members’ and owners’ lines that includes the brain’s other bots’ replies, a channel-discussion thread or forum topic the same plus the root and a per-thread agent, a business chat always
- Hard pre-dispatch gate — an ignore short-circuits before any cost: no typing indicator, no media download, no agent run
- Signals — surface,
- Situational Envelope — every agent chat turn leads with a one-line context header rendered from Surface + Actor + inbound metadata (chat title,
chatId, thread root identity, sender with their numeric id and tier, timestamp in the brain’s timezone), framed as context rather than instruction- Scope-shaped contents — the platform always leads; DM adds sender and time; a group adds title and
chatId; a thread adds its root (“thread of channel post «Journal» #817”);sender_chatmessages render as the channel or an anonymous admin - Dashboard chat envelope — a dashboard chat turn is prefixed with
renderDashboardEnvelope()([Context: Dashboard · <time>]) - Directly actionable — because it names
chatIdand the thread root, the MTProto history tools are usable with no guessing - Turn assembly order — envelope → thread/chat context block → steer directive → reply-context quote → user text
- Scope-shaped contents — the platform always leads; DM adds sender and time; a group adds title and
- Chat Log (observe ≠ engage) — every delivered GROUP message a served chat admits is recorded after the access gate and before the engagement gate, as pure refs with zero extra API calls; a stranger’s line is recorded only where the rule says
context.from: all, a blocked person’s never- What is stored — identity
(chat_id, bot_id, message_id), raw thread root, author snapshot (orsenderChat) with the author’s tier, text/caption, media download handles, reply-to, sent-at; edits re-observe onto the same key so the log never serves pre-edit text; DMs are not logged (business chats are, under their owner’s lane) - Outbound write-back — the bot’s own sends are logged by decorators around the messenger and stream-UI ports (the Bot API never echoes them), self-limiting in SQL to chats that already have observed inbound rows
- Room view across a brain’s bots — Telegram never delivers one bot’s messages to another, so in a supergroup the context block reads every bot lane of the brain, deduplicated by message id: a persona summoned mid-thread sees the other personas’ replies under their names;
context.peers: falsehides them and the header says how many, from whom - Delta-based context injection, never a sliding window — the first turn in a scope gets the last K rows (a thread topped by its root), afterwards only rows the agent’s session has not seen, tracked by a per-
(chat, bot, thread, agent)watermark advanced only at a served terminal, with an honesty line when rows are capped - Untrusted text is blockquote-fenced per line — a per-line
>prefix rather than an open/close fence, because a multi-line message could otherwise forge one of the engine’s own turn blocks - Lazy transcripts — the thread root is transcribed at assembly time through the shared transcript cache; every other row is cache-read-only and renders an explicit “untranscribed” hint instead
- Retention and honest limits — a 6-hourly reaper (30-day age cut, 500-row per-chat cap); the log holds only what the Bot API delivered, and a doctor check warns when privacy mode hides plain-group messages
- What is stored — identity
- Thread-Scoped Agents — a discussion thread or forum topic gets its own agent with a fresh session on its first engaged turn
- Same persona, fresh session — a per-thread lineage key and deterministic slug, warm-started from the chat log (root post plus the last K rows) rather than amnesiac, with brain memory still shared
- Thread-first resolution — an existing binding wins regardless of scope; an unbound thread with effective scope
threadbootstraps and binds (per-thread promise dedup in-process, cross-process races resolved by adoption); a dead binding is dropped in place - Auto-reaped — idle thread agents soft-delete under the same retention TTL and their bindings are swept in the same pass
- Topic-per-Agent — Forum Topics as Delivery Lanes — a Telegram forum topic binds to an agent (
agent_topics), so inside a bound topic the thread is the agent selection and commands become thread-scoped- Parallel lanes — each topic has its own live turn, status card, queue and durable delivery cursor, so two agents stream simultaneously in two topics of one chat
- Lane-stamped Surface — one coordinate (
threadId+threadKind: 'topic' | 'root') makes every Surface-keyed mechanism work unchanged: turn registry partitioning, delivery gating, HITL card routing, stream rebinding, cursor fan-out - Wire translation in one place — topic lanes set
message_thread_id, root lanes reply to the root message (the only way to address a non-forum thread), and a deleted topic degrades to the chat’s main stream - Callback mirroring — a card tap inside a discussion thread operates that thread’s turn and agent, not the chat’s
- Multi-bot per brain — one brain exposes N Telegram bot personalities, each with its own token and persona, sharing memory, skills, grants and database
- Opt-in
bots[]— each declaration carriesname(the persona and chat-agent slug),tokenEnv(the env-var name, never the secret), optionalrole(what an owner’s DM runs; the one bot that omits it is the chief’s bot and runschief.role— two bots omitting it fail the load),policy(the chat frame’s code tier) andsecretary; omittingbotssynthesizes exactly today’s single-bot behaviour bots: []— no Telegram surface at all — an explicit empty roster derivesservices.botfalse (an enabled one throws), keeps theTELEGRAM_*trio optional and drops Grafana’stelegram-opscontact point;hasTelegramSurface(config)is the one predicate consumers askbotIdderived from the token prefix — computable in every process with no API call, so out-of-band delivery recovers the owning bot from a stored ActordeliveryKey = chatId:botId— the chokepoint for all per-(chat, bot) state (chat state PK, Redis reply/HITL/grant/schedule keys, one live turn per personality), because in a DM the chat id is identical across every bot- The chief’s bot carries system messages — engine-internal, actor-less deliveries (the status chat, the
ownerchannel and declared channels, HITL cards with no chat of their own, a scenario surface that names no bot, restart DMs) go through the chief’s bot — the bot whose role ischief.role— whatever token var it reads; per-entity routing is untouched (an interactive HITL card answers in its own chat, a scenario surface naming a bot keeps it). What an agent of a persona role (a role a bot runs that is not the chief’s) writes to the primary owner with no chat — theownerchannel, headlessvoice_send, an approval card with no chat — is sent by that role’s own bot into its DM, so a persona’s heartbeat speaks as the persona and the owner’s reply reaches it. A brain whose bots all run other roles has no carrier for them: they are dropped while the personas keep chatting, and the doctor’sSystem messagescheck fails - Deterministic nobody-or-exactly-one ambient response — a group rule’s default gate still engages only when addressed; a brain that wants ambient chatter elects by persona name over the stable roster, evaluated independently per bot with no shared state
- Opt-in
- Secretary Mode (Telegram Business) — a
secretary: truebot answers the connected owner’s chosen private chats as the owner, with zero bot chrome visible to the counterpart; only an owner may connect it — anyone else’s connection is never served- Owner-scoped identity —
businessOwnerIdis a namespace coordinate on the Surface (in the delivery key, kept by chat-scope, compared by equality), so state survives disconnect, reconnect and credential rotation; the connection id is a credential resolved at send time, never identity - Owner-as-principal auth — business updates authorize on the connection rather than the chat frame, and the owner is the auth subject while the actual sender stays on the Actor
- Rights gating with rotation-safe retry — a pre-check blocks before any API call, an invalid connection refreshes the credential for the next send and degrades this one (never a wholesale retry that would re-deliver already-sent parts), and a paused or inactive peer degrades in place with no retry storm
- Owner-manual-reply back-off signal — the owner’s own messages are recorded into the bound conversation without a turn and stamp
last_owner_reply_at, which a bot’sengage()reads asonBehalf.ownerRepliedAt - Zero chrome, silent failures — status cards, placeholders, keyboards, typing and reactions are all suppressed for business surfaces; a failed turn produces no ⚠️ bubble for the counterpart and exactly one plain failure note in the owner’s control DM
- HITL rerouted to the owner’s DM — approval and question cards land on the owner’s control surface rather than in the counterpart’s chat
- Business context bridge — a business-shaped situational envelope (as-owner framing, counterpart identity, owner’s last manual reply), chat-log lanes keyed by owner, and an owner MTProto bridge that auto-resolves the pool account matching the owner so counterpart history predating the connection is readable — under a blast-radius guard that refuses account-wide tools and any chat but the counterpart’s
- One role per counterpart — a business chat id routes to its own role, so memory line, capabilities, model and persona body are all per person; the bot’s own role is the stranger’s, and a business row follows its route on every message — role switched, model and capability overrides cleared, so the role file alone decides
- Owner-scoped identity —
- Media & Attachment Ingress — photos, voice, video, video notes, animations, audio and documents flow from chat into the agent pipeline
- Token-safe download — the file is fetched to the artifacts tree under a bounded fetch (timeout + byte cap, atomic write); the bot token never leaves the download helper, where it used to be spliced into prompts and third-party requests as a live credential
- Voice replaces text, never duplicates it — all transcribable attachments of a turn are transcribed eagerly through the transcript cache and become the message text, and a pure-audio attachment is then dropped so the agent is not handed the same content twice
- Albums — a media group arriving as N separate updates is collected behind a short debounce and folded into one turn on the carrier (which holds the caption, mention and reply)
- Replied-to media — resolved into a separate field so reply context is never confused with what the user sent: transcribable media goes into the quote block, images and documents are appended and marked as coming from the reply, and a resolution failure degrades instead of dropping the turn
- A file the bot cannot download never costs the message — past Telegram’s 20 MB
getFilecap the ref rides into the turn instead of the bytes and the engaged turn re-fetches it through the media-source seam (MTProto); what no transport reaches is described in the turn text with its reason - Reply quoting — a reply always carries what it points at, including the bot’s own messages (marked as such), using the user-selected quote fragment when the platform provides one; the block is per-line fenced and transcripts of replied-to media are deliberately uncapped
MediaSourceResolver— resolves a media ref back into bytes, trying the Bot API first and falling back to MTProto download for files past the 20 MB cap. One seam for everyone who needs oversized media: the engaged turn’s own carrier attachments, the lazy thread-root transcription and the ambient auto-post- Ambient auto-post — when the brain enables it and the bot does not engage, an observed voice or video is transcribed and the transcript posted as a threaded reply, making a channel searchable text for free
- Outbound Delivery with Real Acknowledgement — out-of-band sends go through the
ui-notificationsqueue, the only place a messenger exists for tool processes- Every branch returns the refs it produced — producers that must not lie about delivery (
file_send,voice_send,notification_sendand brain-toolcontext.notify) await the job and report the delivered message id, so a failed delivery is a failed tool call rather than a confident “sent” with nothing in the chat - Flood waits are waited out, not retried into — when the platform refuses a send for flood control (Telegram 429
retry_after), the job is deferred by exactly that long without spending a retry attempt and lands after the ban; an awaiting tool past its 60 s wait answers UNCONFIRMED with the next attempt’s time — do not re-send - Voice messages — synthesized server-side in the calling role’s voice (since 1.24.0: a role’s
speech:block replaces the brain’sspeech.chainwhole, hot through brain-sync, written byrole_update) and delivered as native voice bubbles viaMessengerPort.sendVoice; a headless caller falls back to the primary owner’s DM resolved at enqueue - Stickers are turn-surface only — platform file ids are bot-scoped, so the branch reads the raw surface and throws rather than substituting a default persona, and the schema rejects a sticker aimed at a named channel or sent from a headless turn
- Every branch returns the refs it produced — producers that must not lie about delivery (
- In-Chat Introspection —
/contextand/stats, the in-chat twins of the dashboard’s context meter and cost page/context— what fills the active agent’s window right now: size vs the model’s window, the role’s policy marks and the distance to auto-compact, cache-prefix stability, session-envelope state (three distinct states, CLI only), conversation size, the tool / skill / MCP surface, the memory budget and the envelope the next turn would carry- Read-only by construction — it reports persisted state instead of rebuilding the prompt, so it neither clones a repo nor runs memory recall, and every reported surface goes through the same resolution the runtime uses rather than a card-local re-derivation
- Mode-honest tool counts —
in prompt · callable · capson both modes: the grant selects the CLI agent’s MCP catalog exactly as it selects the API agent’s tool set, andin promptis what of it rides turn 1 (on API what the role’stoolLoadingkeeps out oftool_search; on CLI what the harness keeps out of its search tool) /stats— turns split into failed vs deliberately stopped, tokens, metered and subscription cost reported apart (never summed, sub-cent charges kept to four decimals), per-model breakdown, top tools, and a queue count that excludes the running turn’s claimed entries- Instruction and outcome are both reported — the header’s
modelRefis what the agent was ASKED to run (a role, a chain, an effort suffix) and names no concrete model;/stats’Lastline and/context’served byname what ANSWERED, taken from the last turn’s cost segments (the heaviest model once ABANDONED chain steps are excluded and the rest summed per model — otherwise a fallover’s burned spend, or a model that ran three times, would outvote the model that answered)./contexttakes its window denominator from that model rather than from the ref, since the snapshot it divides was taken by the model that ran /statsreports fallovers as their own line — count plus the newest transition (from → toorchain exhausted), its reason and the provider’s own error, read back from themodel.reroutedrows already in the ledger; silent on an agent whose chain has never moved- No invented denominators — a runtime that reports no context window gets a bare token count, never a made-up percentage; a turn counter counts turns still in context, and lifetime spend survives
/clearwhile/contextresets
- Wizard Flow DSL — a declarative, type-safe DSL for multi-step wizards in Grammy bots (steps, buttons, text input, multi-select, submit handlers) with Redis-backed payload storage
- Shipped flows — agent creation (role, model, repo,
runInrung selector, free-form model input), adhoc plan authoring, the pipeline runner wizard, and blueprint run selection - One role list, one visible answer — the wizard’s role step and the context-budget report read
listAgentRoles()(brain*.agent.mdpersonas,role-*.mdfiles and engine constants, unioned, identity only), the dashboard’s agent picker readsrole.list(offering only the rolesfeature.listreports on), and every agent card prints the role its row carries
- Shipped flows — agent creation (role, model, repo,
/p— Run any Restate pipeline handler from chat — a thin chat layer over the same trigger helper the CLI and the MCP tool use- Direct grammar —
/p <pipelineId> <handler> [<key>] [<json-input>], validated against the live Restate admin catalogue with the available names listed on a miss - Confirmation policy — read-only handlers (Restate-
Shared, or a conventional read-prefix name) fire immediately; everything else gets a confirm card whose pending args are parked in Redis for 10 minutes and consumed atomically so a double-tap cannot fire twice - Wizard mode — bare
/pwalks pipeline → handler (badged by kind) → key (auto-skipped for stateless services) → input (auto-skipped for input-less handlers) → confirm - Surface propagation — the triggering chat rides along, so HITL approvals and notifications from the pipeline land back in the same chat
- Direct grammar —
- Artifact Browser —
/artifactsgives interactive file and folder browsing in chat with pagination and drill-down, and downloads - Widgets — interactive pages for a person (since 1.25.0) — an agent, pipeline or hook opens a page for the person in its chat: a launch message whose button opens it as a Telegram Mini App, talking both ways while it is open — widgets.md
- Widget files —
features/<f>/<name>.widget.html(read verbatim, never env-rendered) + an optional strict<name>.widget.yml(policy tier, thetoolsallowlist — HITL-gated, agent-control and checkout (shell, files) tools refused —,csporigins, devicepermissions,inputSchema), addressedui://<feature>/<name>; an HTML publication opens ad hoc asartifact:<id>, and a page not written yet as a draft (widget_create({ draft: { path } })— a file in the agent’s artifacts directory up to 8 MiB, or inline HTML — stored as the widget’s own, no publication; a spec that widens the page needsfeature:write); doctorBrain entitiesnames a file or spec that does not load and a spec with no page - Ops —
widget_create(this chat, a declared channel or one person’s private chat with the bot only, so no approval; any chat the bot talks in),widget_update(state snapshot with a version compare-and-set, or a named event — every push numbered, replayable — and publications granted to the open page),widget_close,widget_get,widget_list,widget_widen(more people, behind a person’s approval); an agent acts on its own widgets, the brain caps how many it keeps open (the oldest closessuperseded) - Durable — the widget is a row with an explicit open/closed lifecycle and a TTL closed by a sweep; its launch message is posted and taken down by the bot process
- Submits are inbound events — source
widget, eventwidget:submitted, resourcewidget:<id>: hooks, resource watches and pipelines read them like any delivery; the person is verified and recorded by the engine, never taken from the page; one key, one event - Policy seam — engine → MANIFEST
widgets→ the file → the call: lifetime and its cap, launch-data age, delivery and debounce, size caps (state, events,maxMediaBytesper file made for the page), the push record a widget turn carries, submit rate, default audience, where code’s chat lines go (transcript), per-agent cap, retention - Turns and pipelines — with
deliver: turna burst of submits comes back to the chat’s agent that opened the widget as one turn, under the opening turn’s ceiling; withdeliver: chata pipeline, a hook or any opener that is not the chat’s agent hands each person’s submits to the agent serving the widget’s chat, as that person; every widget turn carries the record of what moved the page since its previous one — events and media one line each, state replacements collapsed into one line, each naming the tool that pushed — outside the untrusted fence, never the state (the page’s data:widget_getanswers it fenced); pipelines getopenWidget/updateWidget/closeWidget/widgetEvents(a durable stream of submits) - The page is an MCP App — the engine hosts it: a Mini App shell on
widgets.<domain>signs the person in with Telegram’s launch data, the page runs in a sandboxed frame on the content origin with no network beyond its spec’scsp, and talks through the page SDK (/sdk/widget.js: input, state, ordered pushes with replay,submit, Telegram’s main/back buttons and haptics) or the official MCP Apps SDK;mediagrants published files as URLs alive only while the widget is open — at open or to the open page — andvoice_render/elevenlabs_sound_effect/image_generatewithwidget: { widgetId, name }put what they make straight onto the page as the widget’s own file (gone when it closes, never in Artifacts), so no bytes ride a state, a journal or a transcript; withoutwidgetan agent gets a file in its artifacts directory - Code pushes to its page — every tool body gets
context.widgets:push(widgetId, { state?, event?, media?, chat? }, { tx? })on the tool’s own transaction (commits or rolls back with its writes;statederived under the widget’s lock, so snapshots never land out of order) andpage, the widget whose page made the call; a page tool pushes its verdict during the call, a tool an agent calls keeps the page in sync without the agent relaying anything; who may push by caller (a page its widget and same-opener siblings, an agent its own, brain code any, an MCP session its own, the webhook door none); an ack with refusal codes, never a throw for the widget’s condition — widgets.md § 5 - Chat lines — a push’s
chatlines write the page’s session into the chat it was opened from, on the same transaction as the state: the brain’s lines in the bot’s voice, the person’s own words asecholines quoted and named by the engine; the bot posts them as one silent message per widget run, ahead of any later answer in that chat, at least once; skipped (never refused) for a widget with no chat, a business chat or policytranscript: off; a page message shows as the person’s quote; a reply to the lines reaches the agent fenced as the page’s — widgets.md § 5 “Lines for the chat” - Gestures are the person’s — a submit, a page message into the chat (
sendMessage) and an allowlisted tool call (callTool, within both the opener’s grant — none once that agent is gone — and the person’s ceiling, never an approval-gated, agent-control or checkout tool; run in the worker for at most the policy’stoolTimeoutSeconds; a failure shows the person only the tool’s ownrefusal(…), else an engine sentence) run as the verified person, share the per-person rate and land in the action log against the widget; a frame that navigates away from the page ends the session - Doctor —
Widget hosting(a bot, both origins https, each hostname resolving),Widget browser sign-in(whether links open in a browser, with the BotFather step),Widget tools(every spec’s allowlist against the deployment’s tools),Brain entities(a file or spec that does not load) - A HITL card’s page — an approval action with
widget, and every input request’s form (the engine’sui://engine/formby default), open a widget bound to the card that answers it as the person, through the one HITL gateway (an approval’sdataheld to itsresponseSchema) and closes when the card is decided anywhere; the engine’s pagesui://engine/formandui://engine/review(item-by-item approve / reject / edit) open withwidget_createtoo; pipelines passwidgettoawaitApproval/requestApproval/requestInput, agents tohitl_request_approval— widgets.md § 12 - On the dashboard and in MCP Apps clients (since 1.25.0) — the dashboard hosts every widget (its chat panel lists the open pages; a request card’s widget action opens its page), and a turn with no chat that serves the dashboard or an MCP client opens a widget with no chat there; an MCP Apps client (Claude, ChatGPT, VS Code) on the bearer door gets your widget files as
ui://resources shown through their own tools, the page’s calls as app-only tools — widgets.md § 13–14 - In a browser (since 1.25.0) — a widget’s link opens outside Telegram after a Telegram sign-in (OpenID Connect through one login bot,
loginSecretEnv), admitted exactly as inside Telegram plus the chat rule’sbrowser:;widget_create({ to: 'tg:user:<id>', launch: false })hands one person a link;widget_list_sessions/widget_revoke_sessionsand a block inaccess.yamlend browser sessions; a HITL card’s page wants a fresh sign-in; doctorWidget browser sign-ingives the owner’s one BotFather step — widgets.md § 15 - Preview (since 1.25.0) —
widget_preview(feature:read) shows the agent a page the way a person will before anyone sees it: a widget file, an engine page, a publication (runtime:read) or a draft not written yet (a file in the agent’s artifacts directory, or{ html, spec? }inline — pages up to 8 MiB; a draft that previews opens withwidget_create({ draft })), rendered in the real host (the Telegram shell with a recordingTelegram.WebApp, or the dashboard frame) under its own CSP in thebrowserservice; screenshots reach the calling model only (modelMedia), a report lists problems first (errors, CSP refusals and stopped requests with the.widget.ymlkey that allows them, overflow, clipping, tap targets, contrast, dark-theme misses, theinputSchemacheck), then what the page sent, its state and its accessibility tree; variants (device × theme × host), scripted steps with element shots, mocks for page tools (with the event and state their code would push) andconnectDomains(no real tool or network ever runs), a tall page in readable tiles, each screenshot compared with the same agent’s last preview of it;preview unavailable: …without the browser — widgets.md § 16
- Widget files —
- Deep Links —
/start run_<scenarioId>,/start pipe_<token>(short-lived Redis handle from the run list) and heartbeat detail links open the corresponding view directly - Chat-Side Reliability Checks —
omnislash doctorvalidates the chat surface itself, speaking with the chief’s bot (the one that carries system messages;System messagesfails when there is none):Telegram privacy mode(warns when a plain-group log would be holed), andTelegram chat reachability(probes everyTELEGRAM_*_CHAT_ID/TELEGRAM_*_CHANNEL_ID/NOTIFY_<NAME>_CHAT_ID, reporting migrated ids and unreachable chats with the same vocabulary the sender gives up on)
Tools & MCP
-
Tool contract —
defineTool()/createTool()— oneToolDefshape is the single source of truth for the LLM schema, authorization, UI grouping and routing decisions;createTooladds the Zod schema and a typedexecute.- Required fields —
name,description,schema,risk,providesCapabilities,label,category(the group key brains target inengine-tools.json) - Three audience flags, three questions —
mcp: false(direct only, e.g. the bash/FS tools),webhook: false(off the HTTP door),agents: false(machine-only — stripped from every agent tool set including'*', and from MCPtools/list) - Runtime behavior flags —
hitl(tool produces a HITL interaction),requiresHuman(stripped from non-interactive agents),alwaysLoad(the engine tier of eagerness: never deferred on API,_meta['anthropic/alwaysLoad']on Claude CLI),enabled(default on/off) - MCP metadata —
annotations(readOnlyHint/destructiveHint/idempotentHint/openWorldHint) andoutputSchema(JSON Schema forresult.data, surfaced asstructuredContenton success) replay: 'live' | 'dry-run'— what the tool does in an execution the loop launched (a pre-merge replay and everything it spawns):dry-runmakes the gateway answer a success markeddryRunwithout callingexecute. Undeclared, an engine tool isliveonly when annotatedreadOnlyHint: true(fail closed), and a brain tool islive— its body readscontext.auth.loopLauncheditselfsourceattribution — auto-stampedengine/project/integrationat registration- Per-tool rate limits —
rateLimit: { perMinute?, perDay? }enforced by the distributed limiter - Description budget 60–300 chars — held by review (
expectDescriptionBudgetchecks a set on demand, with a documented exempt list), because every description is a permanent per-turn tax; three-tier guidance puts post-call prose in the result instead - Result contract —
{ success, data: string }plus optionalmetadata.attachments(media sent to the person’s chat) /metadata.externalContentandpauseDatafor HITL tools; helperssuccess(),failure(),classifiedFailure(code, message, classification) - Images for the model, not the person — a tool that declares
showsModelMedia: true(and nooutputSchema: over MCP a declared one would cost the images) answersmodelMedia: [{ type: 'image', mediaType, data, key? }](base64; png / jpeg / webp / gif; at most 10 images, 5 MB each, 20 MB together — a result past it fails asmodel_media_limit) shows the calling agent a picture: an API agent reads it as an image part of the tool result (a provider that takes no image in a tool result — OpenRouter, NVIDIA — gets a one-line note instead, per chain step), a CLI agent as an MCPimageblock.datastill says in words what the images show. Only an agent’s call carries them: a pipeline’scallTool, the webhook door and a widget page’s tool call get the result without them, and no transcript, journal or event stores the bytes. Within one turn a later image of the same tool andkeyreplaces an earlier one on the next step (it reads[superseded]); an unkeyed image stays for the turn
- Required fields —
-
ToolRegistry— one registration point, many read shapes — tools are registered once at startup (never in handlers) and every lookup funnels through one enabled-gate.- Two stores — executable
toolsand metadata-onlydefs;register,registerDefs,registerProjectTools(auto-stampssource: 'project') - Read surfaces —
get/has,getDefinitions,getTools,getToolNames,count - Capability mapping —
getToolNamesByCapabilities(raw match),getCapabilitiesForUI(wizard multiSelect grouped by domain),getCapabilityPlatformMap(planner prompt enrichment) - One selection predicate on every delivery —
toolRefusal(tool, session, delivery)decides what an agent is offered from itsToolSession(effective grant — the role grant resolved at dispatch, plus the agent’s extras, ∪ live grants — +interactive+memoryEnabled):agents: falseand (over MCP)mcp: falseexcluded,requiresHumanstripped when non-interactive, memory tools refused when the role declares nomemory:block (even under'*'), then the grant —'*'everything, otherwise a matching capability; the two capability-less hatches always (AskUserQuestionsubject torequiresHuman,hitl_request_granton every session).toolsFor(session, delivery)serves the API tool set, the CLI MCP catalog,/contextand the pricing;ToolsGateway.executeForrefuses every agent call outside it (not_offered, naming the tool’s capabilities and pointing athitl_request_grant), for MCP, the fake harness and the API executor alike — a CLI role declaring[]sees only the two hatches, exactly as an API role, until an approval widens it - Every agent tool is grantable — registering an agent-visible tool with
providesCapabilities: []throws (onlyAskUserQuestion/hitl_request_grantare capability-less), so no tool is reachable only under'*' callablevsin prompt—isEager(tool, eager)(mcp-wire.ts) is turn-1 eagerness: thealwaysLoadengine tier plus the role’stoolLoading.eager; it never selects a tool, it only decides whether an offered one loads up front, anddeferredOnTurnOneapplies it per delivery, so the/contextreport, the pricing, the API tool set and the MCP server agree- Deferred tool loading (API) — a role whose deferrable tools (offered, not eager) number more than
toolLoading.deferAbove(default 30 — in practice'*'roles) gets only its eager tools plus onetool_search(the AI SDK’s provider-agnostictoolSearch(), so mixed failover chains work); every other offered tool stays callable once a search discovers it. Tuned per brain (MANIFEST.yaml§toolLoading) and per role (toolLoading:frontmatter):eagerkeeps a role’s hot tools loaded (and marks themalwaysLoadfor direct Claude CLI),0defers everything not eager,falsenever defers (a chain of models that cannot drive a search). The step after a discovery re-writes the cached prefix once; the CLI harnesses keep their own deferral.tool_searchis a reserved tool name - Composed engine surfaces —
registerSystemTools,registerSkillTools,registerAgentControlTools,registerBashTools,registerWorkflowEngineTools(plan + schedule + execution + pipeline + eval in one call, mandatory in every tool-executing process),createIntegrationToolRegistry,registerIntegrationKitTools - Barrel
defs.tsper package — metadata-onlydefineToolexports for consumers that want the static set without instantiating providers
- Two stores — executable
-
ToolsGateway— the single execution path for every tool call — lookup → rate limit → HITL gate → execute → metrics → interceptors, identical for chat, workflow, pipeline, MCP and webhook callers.- Distributed rate limiting — Redis-backed
RedisRateLimiterkeyed by tool category; it fails open on backend trouble (rate_limiter_results_total{result="error"}is the alert signal) so a socket blip never kills an agent turn - Classified error envelope —
ToolErrorDatawithcode+classification:unknown_tool/not_offered/hitl_config/missing_auth_context/missing_grant/validation_error(permanent) andrate_limit(transient, withretryAfterMs)/execution_error(transient); tool-authored envelopes pass through unchanged - Prometheus metrics —
tool_calls_total{tool,status,risk},tool_call_duration_seconds,tool_errors_total, denial recording, with the call source derived fromauth(workflow/pipeline/chat/mcp/other) - Interceptors — fire-and-forget
afterExecute(name, context, result)hooks for usage, logging and analytics; throws are logged and never block the result - Replay rail — under
auth.loopLauncheda tool that acts on the world is authorized as usual and then not performed ({ success: true, dryRun: true }, no rate-limit spend, no grant consumed);context.notifyvalidates and addresses, then delivers nothing; a HITL ask is refused (loop_launched) because no answer can be dry-run ToolExecutionContext— the gateway is the single injection seam that upgrades a plainToolContextwithnotify(channel-addressed delivery for tool bodies, awaits real delivery) andgetServiceToken(providerId)(fresh OAuth bearer)- Per-call side channels —
emitProgress(message)for MCP progress notifications andsignal: AbortSignalfor client cancellation, plussessionId,repoandskillEnv - Live derived name-sets —
hitlToolNamesis a getter and therequiresHumanstrip-set is computed per invocation, so a hot policy flip can’t leave a HITL tool executing without pausing the run
- Distributed rate limiting — Redis-backed
-
Brain-owned tool policy (
constitution/engine-tools.json) — the brain enables or disables engine and integration tools without touching engine code, through a layered{ default?, categories?, tools? }document resolved most-specific-first.- Allowlist and denylist fall out of one mechanism —
default: false+ named categories/tools is only-mode; barefalseentries are except-mode - One gate, no “hidden but callable” —
ToolRegistry.isEnabledcovers listing and resolution, so a disabled tool is rejected by name asunknown_toolon the gateway, MCP and webhook paths - Project tools exempt — the brain governs its own registrations in code, so
default: falsecan never lock a brain out of its own tools; keys naming one warn as ineffective - Hot reload, and no silent keys —
getToolPolicy()is cached and reset by the content watcher; malformed content and a renamed tool key fail fast at load (the error names what the tool became); a tool key no engine or brain tool answers to fails the boot with the nearest real name (logged as an error after a hot reload); an unknown category only warns - What the policy took away is visible —
ToolRegistry.policyDeniedTools()reports the registered tools the file switches off, split into ones it names and ones that merely fell through"default": false; the doctor’sTool policycheck reads it and stays green, because an allowlist denying things is the allowlist working. Without it a denied tool has no surface at all: the published catalog is the enabled set by construction, so a brain on"default": falsesilently loses every category the engine adds after its policy file was written
- Allowlist and denylist fall out of one mechanism —
-
One naming convention for engine tools and capabilities — every engine tool is
<namespace>_<action>(agent_call,file_read,notification_send,email_list), every capability domain singular and spelled as its namespace (db:read,access:widen,artifact:publish). Renames are data, never aliases:RENAMED_TOOLS/RENAMED_CAPABILITIESfeed only the rejection text — a load error forengine-tools.json/toolLoading.eager, a refused grant request,unknown_toolnaming the replacement — and the doctor’s Tool names / Capability names list every old name a brain file spells, with its file. Stored grants and live runs are rewritten by the upgrade’s migration. Table: brain-dev-guide.md § Renamed engine names -
Tool catalog mirror in Redis — the MCP server publishes a serialized
ToolCatalogEntry[]underomnislash:tools:catalogat startup and on every content-watcher tick; the dashboard reads it per request (never at boot) so its bundle stays free of tool runtime deps.- The catalog’s negative space rides along — the same write publishes what the policy denied under
omnislash:tools:denied, because the catalog is the enabled set and what it omits cannot be read out of it. One call for both, so the two keys cannot drift apart.
- The catalog’s negative space rides along — the same write publishes what the policy denied under
-
Engine system tools — HITL & approval — the producer and admin side of the human loop, all reachable from any agent.
- Ask, approve, escalate —
AskUserQuestion(blocking question,alwaysLoad+requiresHuman, offered to every interactive turn;promptFlags.questions: falseremoves it),hitl_request_approval(detached card,hitl:request),hitl_request_grant(capability escalation — any catalog capability, held or not — offered to every agent),hitl_request_connection(OAuth consent for an MCP server or brain provider,integration:execute),git_request_app(one card whose link registers or installs the deploy’s GitHub App,integration:execute— § Deploy-Owned GitHub App),hitl_list,hitl_respond,hitl_cancel
- Ask, approve, escalate —
-
Engine system tools — agent management and observability — spawn, drive, inspect and stop other agents from inside an agent turn.
- Write side —
agent_create,agent_task(spawn-and-wait withtimeoutMs+timedOutsnapshot),agent_call,agent_enqueue,agent_await,agent_answer,agent_escalate; the session commands areagent.*ops —agent_send(the chat verb: the agent’s inbox, merged with its other chat messages,refsriding as the text’s<refs>block as onchat_send;agent_enqueuestays the programmatic turn),agent_send_command(a harness slash command),agent_compact,agent_clear,agent_flush_mailbox/agent_clear_mailbox/agent_delete_mailbox_item,agent_stop/agent_delete({ id }or{ ids }; never the caller itself),agent_update({ id, patch }, theAgentPatchfields; a widening — a model whose harness ignores the grant, capabilities the caller does not hold, a less confinedrunIn— is refused,agent_widenmakes it underrole:widen; a rename is a person’s),agent_align(drop the row’s own model and extras: the role file decides; refused from an agent when that widens, asagent_update),agent_intercept/agent_release(agent:intercept, restricted, HITL: take a live workflow agent over and hand it back).agent_send,agent_send_command,agent_compactandagent_flush_mailboxrefuse an agent a live workflow run owns (an active interception exemptsagent_send— for a person, or the agent that opened it) - Read side —
agent_list,agent_get,agent_history,agent_tail, used by supervisors and by thesub-agent-supervisionskill
- Write side —
-
Engine system tools — memory —
memory_search,memory_read,memory_write,memory_edit,memory_delete; the whole family is registeredalwaysLoadso a memory-enabled agent reaches for memory on turn 1 instead of fabricating “I couldn’t save”. -
Engine system tools — skills —
skill_get(SKILL.md body without frontmatter) andskill_read_resource(a whitelisted file fromscripts//references//assets/, or the overridden engine version underengine/), bothskill:read,safeandalwaysLoad(the prompt names them), and both serve only enabled, eligible skills. -
Engine system tools — notification and delivery — the verbs an agent uses to reach a human without an approval card.
- Direct delivery —
notification_send(a named channel from project config, or — omitted — the turn’s own chat /statuswhen headless; the delivery verb for headless/heartbeat agents, auto-chunked, returns after real delivery),voice_send(TTS + playable voice message into the conversation or owner DM),file_send(document attachment into the current conversation) - Publishing —
artifact_publish/artifact_unpublish(artifact:publish) turn an artifacts-directory file into a titled web URL with optional expiry, private by default, revocable; the rest of a publication’s life is theartifact.*ops (§ Artifacts & Deliverables › Artifact publishing)
- Direct delivery —
-
Chat access tools — the
access.*engine-api ops, each an agent tool and a dashboard procedure:access_overview(per bot, every tier of its rules — defaults, the DM, each served or only-seen group — with its own entry, the rule in force and the tier behind each field; identities and owners; the roles a rule may name; bot health; warnings; the file’ssha),access_identities(who is in a chat and the tier each one has there; with nobot, every identity — a person or a chat that speaks — the brain lists and where their tier is set; withidentity, that identity across every chat and DM),access_explain(simulates one message —bot,chat(dmor a group),sender(an id or a@username) orsenderChat,text,addressed,reply, astatusstub — through the bot’s own resolver and the brain’s real policy code; an ordered trace setup → served → rule → tier → engage → commands → limit withdecided, the step that said no; changes nothing),access_read_file(the whole file as written, an invalid one included),chat_history(runtime:read— what a bot saw in a group and said there, newest first; DMs are not logged),agent_list({ chat })(a chat’s chat agent and thread agents),access_update/access_widen(one write in one of three shapes — typed edits (identity,ruleondefault/dm/ a group,remove_chat,unknown_chats), one tier as YAML, or the whole file — validated as the bot loads it, classified for widening whatever the shape,dryRun+expectedSha, written by compare-and-swap with comments preserved, answering{ sha, diff, consequences }; a@usernameresolves through the chat log, then the brain’s Telegram account). Each write is one action-log row. Thechat-accessengine skill carries the procedure — chat-access.md -
Engine system tools — runtime, health and diagnostics —
runtime_overview(live agent execution across the deployment via Restate introspection, plus every invocation of any service Restate paused after its retries ran out),health_overview(the generated tool of thehealth.overviewop since 1.23.0:worst, this process’s health probes, the service roster — heartbeats beside Prometheus’ scrape, with container restarts —, the brain-sync daemon’s state and the Prometheus / Grafana / Loki sections as{ value }or{ error }, which source answered (sources), with an explicitcaveatslist),doctor_check(engine self-diagnostics, including live LLM and embedding provider liveness read off the circuit-breaker record — the generated tool of thedoctor.checkop, so it returns exactly the report the dashboard’s Doctor page renders;maxAge(seconds) takes a report this process ran that recently,checkedAtsaying when — what the dashboard’s badge polls with;only: "issues"trims it to the warn/fail results for triage),infrastructure_endpoints(HITL-gated endpoint discovery — the generated tool of theinfrastructure.endpointsop since 1.24.0, output unchanged),brain_search(runtime:read, since 1.21.0 — free-text search over workflow runs, agents, scenarios, blueprints, skills, pipeline runs, self-evolving cycles, published artifacts and (since 1.24.0) requests, capabilities and tools (withgovernance:read), bots, chats and people (withaccess:read), ranked, one group per kind withtruncated, each hit carrying the id the kind’s own tools take and the entity it belongs to; a kind that could not be searched is named infailed— the op behind the dashboard’s Cmd/Ctrl+K palette). -
Engine system tools — data and queues —
db_queryanddb_execute(search_pathpinned topublic, soomnislash.*needs qualifying;;-separated statements run as one transaction and every statement reports on its own, in order —{results: [{command, rowCount, rows?, truncated?}]},rowspresent exactly when the statement returns a result set, so “returns nothing” and “returned zero rows” differ by shape, and one 1000-row cap shared by the whole call; values bind to$1…$nthroughparams(scalars; JSON as a string cast with$1::jsonb; withparamsthe call is one statement, Postgres’ own rule), so no value is ever quoted into the SQL text;db_executetakesdryRunto rehearse a write and roll it back; a failed statement comes back with everything Postgres diagnosed —DETAIL/HINT/CONTEXT/RELATION/SQLSTATE, each clipped on its own at 500 chars, and the error position as psql prints it,LINE n:with the line’s text and a caret under the character — not just the message, so an agent that mistyped a column reads Postgres’s own “Perhaps you meant …” and sees where instead of guessing again),db_schema(tables, views, materialized views, foreign tables and partitioned tables, read off the Postgres catalog; each column’s type spelled exactly as a cast takes it —text[],numeric(10,2),omnislash."ExecutionStatus"— and an enum column’s (or enum array’s) labels asvalues, in the enum’s own order; coverspublicandomnislashby default, one optionalschemato narrow, each entry tagged with the schema it came from, each table and column carrying its own DB comment asnote, and a reportedtruncatedwhen the description hits the column cap),kv_get/kv_set/kv_delete(kv_getthe generated tool of thekv.getop since 1.25.0, its name, input and answer unchanged),queue_get,queue_create_job(shipsenabled: false),event_ingest(records an external event in the durable inbox — hooks and dispatch react asynchronously — deduplicated permanently by(source, idempotencyKey), optionalresources; engine-reserved sources refused; since 1.24.0 the generated tool of theevent.ingestop:reemitOf: <eventId>alone records a stored event again under a fresh key, so it routes to its hooks and dispatch once more, and the answer names itssource; the webhooks door keeps its ownevent_ingestatPOST /tool/event_ingestfor an external system — fresh events, the answer{ eventId?, status }),brain_sync(force an immediate bidirectional brain git tick — the generated tool of thebrain.syncop since 1.23.0:{ outcome: 'synced' | 'skipped', durationMs, conflicts, message },skippedwhen no brain-sync daemon runs).- AI-first, because an agent MODELS in these tools and does not just read —
db_execute’s description names the DDL vocabulary of state.md (CREATE TABLE,ALTER TABLE … ADD COLUMN,CREATE OR REPLACE VIEW,DROP VIEW) and says the;-batch is one transaction returning every statement’s rows in order, soRETURNINGworks and aDROP VIEW; CREATE VIEWswap can never leave neither;db_query’s tells the agent to qualifyomnislash.*, to bind values viaparams, and to read the retention notesdb_schemacarries before it treats engine rows as history; all threekv_*descriptions redirect agent state to tables and keep KV for pipeline cursors, dedup and last-seen markers db_schemaanswers the three questions a column list cannot —kind(table/partitioned/view/materialized/foreign, fromrelkind), thedefinitionof a view or materialized view (pg_get_viewdef(oid, true)— the only way to check the view you just wrote says what you meant) and a table’sconstraints[](pg_get_constraintdefover PK / UNIQUE / CHECK / FK, so an invariant the schema already enforces is not re-derived in prose every turn). The whole answer is onepg_class⋈pg_namespace⋈pg_attributeread —information_schemacould produce none of the three, named every enumUSER-DEFINEDand every arrayARRAY, and did not list matviews at all — run under the samesearch_path = 'public'asdb_query, so every type, default and definition is spelled the way it compiles there- One pg type mapper, shared by all three paths — applied per column inside the executors from
fields[].dataTypeID, sodb_query,db_executeand the<agent-state>render can never disagree about what a column is:date→YYYY-MM-DD(built with the LOCAL getters, the exact inverse of how the driver parses it —toISOString()loses a day east of UTC),timestamp[tz]→ ISO,numeric/int8/COUNT(*)→ number,interval→ its Postgres text form. Notpg.types.setTypeParser, which is process-global and shared with Drizzle. Recorded trade-off:int8/numeric→numberloses precision past 2^53, chosen because the consumer is a model reading a JSON value or a markdown cell, where a"3"costs every reader a coercion and a lost digit past 2^53 costs nobody anything — and a “safe string fallback for big values” is rejected outright, since two representations of one column type is worse than either - Per-transaction settings carry the caller’s identity — both executors take an optional settings bag applied as
set_config(name, value, true)with name AND value bound (SET LOCALtakes no parameters), the name checked against a qualifiednamespace.namepattern so only custom GUCs are settable and the built-ins that ARE the executor’s promises (search_path,statement_timeout,role) cannot be smuggled through it. The tools fill it fromauth.agentId, never from a tool argument
- AI-first, because an agent MODELS in these tools and does not just read —
-
What is in the brain’s tables and key-value store (since 1.25.0) —
db_overview(db:read): every table and view ofpublic(the brain’s migrations, the tables agents create, the<namespace>_*state views) largest first, each with its kind, estimated rows (Postgres’ statistics —n_live_tup, the planner’sreltupleswhen that reads 0 — never acount(*)), size (pg_total_relation_size) and the rows inserted / updated / deleted since the statistics reset (statsSince) — churn without a history table; the key-value scopes with their live keys, stored size and last write; totals, and the whole database’s size for scale. No row data and no values.db_tail(db:read): one table’s or view’s columns (name, type as a cast spells it, nullable) and latest rows —limit≤ 50, default 20 —, newest first bycreated_at/createdAt/updated_at/updatedAtwhen it has one, else physical order (ctid; a view unordered, and a table past ~100k rows with no index on that column unordered too — the sort would hit the read timeout), each cell its text form (jsonbcompact) cut at 500 characters with…; the name is looked up in the catalog, so one that is no table or view ofpublicisnot_foundand never reaches SQL as given, and an agent’s call binds its id likedb_query, so a state view scoped to it reads the same.kv_list(db:read): one scope’s live keys, newest written first, each with its value’s stored size, when written and when it expires — never the value (kv_getreads one); at most 200,truncated. All read-only, on the read pathdb_queryuses (read-only transaction, the read timeout). The dashboard’s System › Data reads them. -
Engine system tools — filesystem and shell (API agents) —
bash,file_read,file_write,file_edit, registeredmcp: falsebecause CLI harnesses ship built-in equivalents. -
Engine read ops — capabilities, the atlas, repos (since 1.23.0) —
capability_list(governance:read: every capability as the tool gate merges it — engine set, the brain’s two policy files — with its risk, gate, source, the brain’s change to an engine default (policy), its providing tools and how many grants hold it and requests ask for it, plus each policy file’s state and theshaa policy write swaps against; since 1.24.0toolsKnown— false when the tool catalog could not be read, so everytoolsis then unknown, not empty),capability_get(since 1.24.0,governance:read: one capability in full — its row, which tier each of risk / hitl / description comes from (provenance:engine,brain— yourengine-capabilities.jsonchanged it —, orproject), the policy file a write of it lands in, every providing tool, the scenarios whose plan requires it, the entities whosegrants:names it, its grants per status and open requests),capability_read_file(since 1.24.0: a policy file as written with its state andsha— a missing engine file reads as the engine defaults),atlas_structure(feature:read) /atlas_timeline(run:read,{ from, to }epoch ms, ≤ 7 days; a reader that polls passes theatof the answer it holds assinceand gets only the traces that moved, withids— every trace of the full read — to merge them by) — the Brain Atlas read model (@omnislash-ai/atlas) as ops, andrepo_list(config:read, a new safe capability: the brain checkout — the default — and every attached repo with its slug and remote). The dashboard binds the same ops; Home,/permissions,/permissions/tools,/atlasand the repo pickers read them. -
The boundary and the repos as ops (since 1.24.0) —
endpoint_list(runtime:read): every address the deployment is reached at (inbound— the brain’s event sources at/event/<source>with today’s events and errors in the brain’s zone, the tools the webhooks door serves at/tool/<name>with their risk, the MCP server, the engine’s public services) and every one it reaches out to (outbound— dispatch webhooks with the events they subscribe to, notification channels and whether their chat resolves, the engine’s dependencies), each by an endpoint id<direction>:<type>:<name>, with the tool door’s HTTP contract, today’s traffic per source and the dispatch summary — public addresses only, never an internal host or port, and a dispatch webhook by its origin alone (its path or query often holds its secret);endpoint_get: one endpoint with what it takes and who listens — a tool’s schema and a sample body, an event source’s subscribers (hooks on its events, dispatch targets forwarding them) and newest event, a dispatch webhook’s last deliveries (delivered,rejectedby a 4xx,blocked,skipped— nothing sent, the target was not configured when the job ran —,failedafter its retries, still queued) with what the read covered (coverage: jobs scanned, the oldest seen, whether older ones were left unread — the queue keeps a bounded history) and its first event’s sample, the MCP server’s health, live sessions, exposed tools, resource templates and OAuth clients;endpoint_history: an event source’s events newest first, keyset-paged;endpoint_send(endpoint:send, moderate, no approval): a test to a dispatch webhook (its first event’s catalogue sample,test: truein the body andX-Omnislash-Test: true, through the same queue) or a notification channel (one fixed line);event_ingest({ reemitOf })re-emits a stored event. Since 1.25.0 a tool row names itssource(engine/brain), an event source’stodayandbySourcecount the events its hooksprocessed, andhttp.baseUrlis never null — the public origin, else the webhooks app’s loopback port, which answers only on the webhooks app’s own host (a curl a person runs there; another container, an agent’s included, cannot reach it — the one non-public address the op names).repo_listrows carry each repo’s GitHub repository and visibility, sync state, attach time, the lane that authenticates it andoutside(no installation serves it);repo_create/repo_delete(config:write) attachowner/nameunder a slug (a slug in use is refused, never re-pointed) and detach one (the checkout stays on disk);git_overviewadds each installation’sstatusandcreatedAtand every repository the installations reach (available, with the slug it is attached under or would get — that walk is cached five minutes and dropped when GitHub reports an installation change, sorepo_listandgit_overviewshare it);git_authorize(integration:execute) is the App registration / install link for a person — an agent calling it is refused towardgit_request_app, whose card carries the link (the link never lands in a tool result). The dashboard’s Connections pages read the same ops. -
The tool catalogue as ops (since 1.24.0,
governance:read) —tool_list: every tool this deployment’s agents can be offered, with its label, category, platform, source (engineorbrain), declared risk, every capability it provides and whether one waits for a person (gated), what brings it (provider: an engine platform, a braindefineIntegrationbundle, the brain’s own tools), webhook and MCP exposure and whether it loads up front (loading); filtered byq,source,risk,category,exposed: "mcp",capability,provider(the tools one provider brings), with counts over the whole catalogue, the engine toolsconstitution/engine-tools.jsonswitches off (switchedOff) and the brain’s tool loading (loadingDefault).tool_get: one tool with its input and output JSON Schema, annotations and each capability’s risk and gate (an unknown name is refused with the nearest real one).tool_summary: how tools were called over1h,24hor7d— Prometheus’ totals, call trend (five-minute buckets over the hour, hours over the day and, since 1.25.0, hours over the week too — its own step, never folded), breakdowns by risk, source and error type, and per tool — withprometheus.state(up,unreachable, orfailed— it answers but the query failed) and its retention said, every Prometheus sectionnullunless it isup, and calls by agent from the conversation log beside it (which answers either way, with its own coverage);toolscopes it to one tool. The dashboard’s tool pages read the same ops -
Logs, events, configuration and the emergency stop as ops (since 1.25.0) — the reads an agent had no way to make:
log_overview/log_list(runtime:read) — the deployment’s container logs as Loki holds them: the services and levels it has lines of and how long it keeps one (retention— a window older than that reads empty, not quiet), then one window’s lines (default the last 15 minutes) by service, level, the run / agent / pipeline-run / task id a line carries and a literalsearch, newest first, a page at a time; polled, not followed — ask again withfrommoved for what came since;preconditionwhere no Loki runs (omnislash dev);event_tail(event:read) — the events themselves as they went over the bus, newest first, by name andsince, from the capped tail of the last 20,000 (tailFromsays where it begins: a debug window, never a record) — 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 a role that serves anyone but an owner should not holdevent:read;config_get(config:read) — the configuration as the engine runs it, each value with its tier: every MANIFEST key (brainwhere the file sets it,enginewhere the default fills in), the data sections ofomnislash.config.tsand every declared environment variable 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;system_stop/system_resume(system:stop— restricted, a person approves, granted to no role but a'*'one;system_statusunderruntime:read) — the emergency stop: ONE command brakes the scheduler, pauses every queued, running or retrying workflow and pipeline run — and one that needs you while its other tasks still work — and cancels every agent turn in flight (the caller’s excepted), recorded as one action; resume undoes exactly what that stop did — a brake or a paused run that was there before stays; a run that will not resume keeps the stop standing for it alone until a resume brings it back. The dashboard binds the same ops and its own click needs no approval -
Engine system tools — schedule, workflow and evaluation —
schedule_overview,schedule_create_prompt(scheduler domain, below), the scenario and plan ops (scenario_*,blueprint_*, below) plus the workflow-engine group registered byregisterWorkflowEngineTools(run_diagnosis,run_update_blueprint,pipeline_list);run_list/run_get, theeval_*tools and everyloop_*tool (loop_status,loop_get,loop_trace,loop_summary,loop_list_agents,loop_history,loop_get_cycle,loop_get_proposal,loop_policy,loop_join,loop_set,loop_widen,loop_hold,loop_release,loop_rearm,loop_analyze,loop_create_idea,loop_cancel_idea,loop_ack_failure) are engine-api op tools generated from therun.*,eval.*andloop.*ops (Run read tools, Eval tools, below) — the workflow surface itself belongs to the workflow slice. -
MCP server — one HTTP process serving every agent — Streamable HTTP on
/mcp(default port 3100) with all heavy DI shared across sessions, plus a--stdiotransport for Claude-Desktop-style single-parent use.- Session lifecycle — one
(Server + Transport)pair perinitialize,Mcp-Session-Idon every subsequent request,DELETEto terminate; a 15-minute idle reaper (60 s sweep) skips any session with in-flight requests - Session URL parameters — identity (
agentId,blueprintId,scenarioId,taskRunId,runId,pipelineRunId,pinnedRunId,planningSessionId,conversationId,role) viaparseAuthFromUrl(pinnedRunId= the replay pin a turn on pinned content hands to every sub-agent it spawns), the run the session’s work counts towardrunRef=(JSON{ runKind, runId }, what a brain tool records as lineage; malformed reads as absent), the execution the session’s turn runs insideexecution=(JSON{ id, entity }— what its sub-agents inherit and what a workflow / pipeline it starts records as its parent; malformed reads as absent), the boolean flagsunattended=1/loopLaunched=1(only the literal1reads as set), plus execution traits viaparseSessionOptionsFromUrl—interactive=false/memory=false(closed-loop: only the literalfalseis recognized;memory=false= the role declares nomemory:block, so the memory tools are refused) and the agent’s memory linesmemoryScope/memoryReadScopes/memoryWriteScopes(open-valued, re-validated againstMemoryScopeSchema— the writable set keeps lines only and drops'*'or an authored corpus — unrecognized → default line;memoryWriteScopesis always emitted for an agent, and an EMPTY value means writes nowhere — a replay’s turn, or an agent without memory — while an absent one means home line only) - Per-session catalog filtering —
mcp: falsenever exposed,agents: falsenever exposed,requiresHuman: truestripped and rejected inCallToolfor non-interactive sessions;CallToolre-checks all three flags so a harness cannot call a name it never saw - Resource-exhaustion caps —
MCP_MAX_BODY_BYTES(4 MiB → 413) andMCP_MAX_SESSIONS(1000 → 503) - Security — loopback
MCP_BIND_HOSTdefault,MCP_ALLOWED_ORIGINSallowlist enforcing 403 on browser origins (DNS-rebinding defense), SDK-delegatedMCP-Protocol-Versionvalidation,MCP_ADMIN_TOKEN-gated/admin/sessions - JSON-RPC error envelopes — every
/mcpguard (403/400/404/405/413/503/500) answers in the spec’s error shape;/health,/ready,/metricskeep plain JSON - Progress and cancellation —
notifications/progressdriven byToolContext.emitProgresswith a monotonic counter, andnotifications/cancelledwired toToolContext.signal; opt-in per tool with a documented catch-and-translate pattern - MCP logging primitive —
logging: {}capability plus a pino→notifications/messagebroadcast with per-session level filtering - Telegram log transport — optional INFO+ log mirror into a channel (
TELEGRAM_MCP_CHANNEL_ID), buffered at 500 entries and flushed every 2 s with serialized sends - Bearer door — bearer-keyed public access at
mcp.$DOMAIN/mcpin domain mode (Caddy proxies only/mcp, refusing keyless requests at the edge): a request carryingAuthorizationorX-Forwarded-Formust presentMCP_TOKENand is pinned tosubjectId: 'mcp:default'; docker-network callers stay keyless - Self-serve grants through the door —
hitl_request_grantfrom a bare-principal context routes through themcpHITL producer, and the Telegram/dashboard approval card names the standingsubjectscope, so approve mints a grant covering every session of that key - Deliberately not implemented — the MCP Tasks primitive,
resources/subscribe, resumability/EventStore, and OAuth on the bearer door (static bearer only)
- Session lifecycle — one
-
MCP resources — read-only data by URI —
omnislash://skill/{name}(eligible skills,text/markdown) andomnislash://hitl/{id}(own pending interactions only; cross-agent reads return-32002without disclosing existence), with RFC 6570 templates for late fetch.listChangedfan-out —ResourceRegistry.notifyListChanged()debounced 250 ms, fired fromhitl:requested/hitl:respondedand from the skills content watcher; per-session unsubscribe on server close- Extension point — implement
ResourceProvider(prefix +list+listTemplates+read) and register it inregisterSystemResources
-
Per-agent external MCP servers — an
.agent.mddeclaresmcpServers: { name: { type: 'http' | 'sse', url, headers? } }andresolveMcpServersinterpolates${VAR}/${VAR:-default}in URL and headers, then reshapes per harness (ClaudeMcpHttpServerConfig, Codexhttp_headerswith SSE upgraded to HTTP, OpenCodetype: 'remote').- Merge order —
.mcp.jsonin cwd (Claude only) < agent-level < the engine’s ownomnislashentry, which always wins and cannot be renamed - Unauthorized by the engine — external MCP output bypasses
ToolsGateway, so no HITL, no rate limit, no per-tool metrics and no prompt-injection wrapper; authorization is the external server’s problem - Playwright sidecar — an official
playwright-mcpservice ships enabled by default (services.playwright), reachable athttp://playwright-mcp:8931/mcpin Docker, probed byomnislash doctor, with two documented traps (pin acli:model ref; give every${VAR}a default) - Preview browser (since 1.25.0) — a
browserservice (Chromium headless-shell,chromedp/headless-shell) ships enabled by default (services.browser): the webhooks app renders widget previews in it over CDP, on apreviewnetwork only the two share (internal in production — the browser has no way out; every request a page makes is answered from memory, a mock or a guarded fetch of itsresourceDomains); doctorWidget previewsays whether a preview can render now
- Merge order —
-
Client configuration recipes — ready-made
.mcp.json("type": "http"required), Codexconfig.tomland OpenCodeopencode.jsonsnippets, plusbuildOmnislashMcpUrl(url, auth, traits)used by every CLI harness launch.
HITL (Human-in-the-Loop)
-
Unified HITL Gateway — every human-blocking interaction in the engine flows through one
HitlGateway, onehitl_interactionstable, and one event pair (hitl:requested/hitl:responded); parallel HITL channels are banned.- Two orthogonal axes —
kind×producer—kindowns the response Zod schema and card renderer,producerowns required context and resume/dispatch side effects. A missing(producer, kind)cell throwsHitlError.unsupportedComboatrequest()time - Six kinds —
question,grant_request,approval,input_request,oauth_consent,interception; response shapes live inHITL_RESPONSE_SCHEMAS_BY_KINDand are re-validated on everyrespond()because they cross a serialization boundary - Producers —
agent,planner,workflow,schedule_manual,schedule_cron,pipeline,connection,mcp,loop,rating,chat(the unknown-group admission card, answered in Telegram only),budget(a spent money budget’s raise card — budgets.md); each owns itsvalidate()invariants and per-kind handler cells in a closedPRODUCERSregistry - Who may answer a card from chat — not configurable — approvals and grants only an owner; a question its addressee (the person the asking turn served) or an owner.
authorizeResponderruns insiderespond()/cancel(), so a refused press leaves the card pending and the presser gets a private alert. A question stays in its chat for its addressee; any other card raised by a turn serving a non-owner is delivered to the HITL chat (with a “raised by … in …” line), never into a group where anyone could press it; pending reply modes are keyed by (chat, person), and the agent inbox never merges two people’s messages into one turn - Gateway API —
request(),respond(),respondByAgentId(),cancel(),cancelPendingForRun(),cancelPendingForAgent(),cancelPendingForReapedAgents(),sweepForInterception(),cancelExpired(),getById(),reconcileUndispatched(),dispatchPendingForAgent(),releaseDue(),forwardToHuman() - Narrow facades —
HitlGatewayRequester(produce only) andHitlGatewayAdmin(respond/cancel/getById) so a container can wire one side without the other;HitlGatewaystructurally satisfies both - Injected side effects —
HitlGatewayDeps extends Partial<HitlCallbacks>;createHitlCallbacks()(agents-api) pluscreateWorkflowHitlCallbacks()(workflow-engine) supply the full set, and an unwired callback leaves the row for reconcile instead of failing respondedBy: Actor— a single discriminated union (telegram/dashboard/agent/mcp/webhook/system/ …) replaces the old{responderType, responderId, respondedVia}triple- Surface routing — delivery surface is
req.context.surface ?? <lineage surface> ?? deps.defaultHitlSurface(the lineage surface: below, under Sub-agent questions);TELEGRAM_HITL_CHAT_IDnames a dedicated HITL chat separate from the status chat, and is optional — unset falls back to the primary owner (the first id inTELEGRAM_ALLOWED_IDS) - Conversation binding — each row is bound to an
agent_conversationsrow (caller-supplied conversation id reused, otherwise a placeholder minted in the same transaction);conversation_eventsrows (hitl_request/hitl_response) carry the full interaction snapshot so timelines render without a join
- Two orthogonal axes —
-
AskUserQuestion— one question channel across API, Claude, Codex and OpenCode — a single tool name and a single canonicaltool_usesentinel guarantee “question visible to the model → run paused → exactly onehitl_interactionsrow”.- Exactly-once producer table — API/Claude-MCP/Codex-MCP/OpenCode-MCP produce the row from
execute(); OpenCode’s nativequestiontool and Claude’s bare builtin are covered by parser/queryRunner safety nets - Generic HITL-tool pause —
ToolsGateway.hitlToolNamesis derived fromToolDef.hitl === true(todayAskUserQuestion+hitl_request_grant); API pauses viaprocessApiStream’s wrapped executor, CLI via per-harness MCP-prefix stripping +processCliStream. Adding a HITL tool needs zero execution-code changes - Runaway guards — a model that ignores the “execution paused” hint and emits another tool call gets a synthetic terminal result and a closed/aborted harness stream; the guard is disarmed by an error tool_result so a self-healing retry is not killed
- Failed-call pause withdrawal — an error tool_result for the armed toolId withdraws the pause, because a failed call created no row and pausing would strand the run in unresolvable
awaiting_input - Question keyboards — single-select option buttons,
multiSelecttoggle chips with the selection bitmask carried incallback_data(no Redis, no TTL) plus Submit, multi-question fallback to typed text; the dashboard mirrors the same{ answer }contract - Non-interactive agents —
interactive: falsestripsrequiresHuman: truetools by the one selection rule on both paths (the API tool set; the CLI MCP session’s?interactive=falsefilterstools/listand refusesCallTool), and swaps the prompt module towithout-questions.md - Per-provider prompt nudges —
with-AUQ-claude.md/-codex.md/-opencode.md/with-AUQ.mdrouted bySystemPromptBuilder.withQuestionPolicy
- Exactly-once producer table — API/Claude-MCP/Codex-MCP/OpenCode-MCP produce the row from
-
Sub-agent questions — the parent first, then the person, on the person’s chat — a question from an agent another agent spawned (
agent_task/agent_call/agent_enqueue/agent_create) is routed by the delegation tree instead of landing context-free in the owner’s HITL chat.- Lineage surface — a sub-agent runs surfaceless on purpose (its stream must not talk into the parent’s chat), so for a surfaceless request naming an agent the gateway walks the asker’s ancestors (
agents.created_by, nearest first) and delivers to the first one’s chat — the chat its current turn serves, else the chat it is scoped to — before the default HITL chat. Applies to questions, grant requests and OAuth consent alike; a business chat still reroutes the card to the owner’s DM - Parent head start (
delegation.parentFirstMs) — a delegated QUESTION waits for its parent before a person is asked: engine default 120 s →constitution/MANIFEST.yaml§delegation→ the asking role’sdelegation:frontmatter;0= ask a person at once (e.g. a role operating the person’s live desktop). The row is held (deliver_after) with no card pushed; the reconcile tick (60 s) releases it when the head start is over. Grant requests and OAuth consent are never held — a parent cannot approve them - The parent’s view —
agent_task/agent_call/agent_awaitreturnawaiting_inputwithpendingHitl: { hitlId, kind, questions, deliverAt }(agent_getundercurrent); the parent answers withagent_answer, escalates withagent_escalate(note shown on the card), or does nothing and the card reaches the person atdeliverAt. No grant card for the parent: both tools rideagent:execute - Card context — the person’s card names the asker (
displayName· role),delegated by <parent>with an excerpt of the task, and the parent’s escalation note when there is one
- Lineage surface — a sub-agent runs surfaceless on purpose (its stream must not talk into the parent’s chat), so for a surfaceless request naming an agent the gateway walks the asker’s ancestors (
-
Detached approvals —
hitl_request_approval(agent tool) andrequestApproval(pipeline SDK) fire a decision card and return thehitlIdimmediately, so a batch producer never freezes on human latency.- Custom actions —
payload.actions: ApprovalAction[](1–8, id/^[a-z0-9_-]{1,32}$/, reserved idscustom/cancel/chatrejected, label ≤ 64 chars,style∈primary|success|danger|muted); omitted → default Approve/Deny - Structural response union —
{ actionId, note? }from a button tap (the note: a short why a surface with a text field may attach),{ customReply }from a Telegram native reply on the card or the dashboard “✏️ Custom…” input;isApprovalActionnarrows - Three composable consumers — a
*.hook.mdonhitl:respondedfiltered bykind+tags, a later poller viahitl_list, oronReply: 'conversation' onReply: 'conversation'— a free-text reply is recorded on the row first, then handed to the surface’s conversation-owning chat agent as a normal chat turn, the text as written: the card’sreplylink attaches the raising feature’sreplycontexts and the quote names the card; button taps never converse, and dashboard replies stay data-only- Card identity is durable — a native reply finds its card through the message’s origin row (
message_origins.hitl_id, on every message the card became), not a 24 h cache: a reply routes to the card while it is pending, however old, and is an ordinary message once it is decided tagslifted onto the event — copied frompayload.tagsto the event body so hookmatchesFiltercan use array-contains semantics without nested pathscancellable: false— hides the ✕ Cancel button on both surfaces and rejects forgedhitl:{id}:cancelcallbacks; a caller holdinghitl:writecan still force-close viahitl_cancel- Auto-layout — Telegram renders 1 button/row when any label exceeds 20 chars, else 2/row; the dashboard bulk Approve/Deny bar excludes custom-action cards
- Custom actions —
-
Blocking approvals and questions for pipeline steps —
awaitApproval/askQuestion/requestGrant/requestInputcreate rows carryingcontext.pipelineAwakeableIdand suspend on the Restate awakeable, resolved by the producer cell’sunblockOnResponse(pipeline runtime itself is the workflow slice’s). -
A pipeline’s approval expires by default — an
awaitApprovalcard gets a deadline when the call names none: 168 h (engine) →pipelines.approvalExpiresInHoursin MANIFEST (0= no default deadline; read live at each approval) → the call’s ownexpiresInMs(null= never). A waiting run keeps its journal across every deploy, so a card nobody answers must end; expired, the helper throwsApprovalCancelledErrorwith anexpired:reason.requestApproval(detached — nothing waits) keeps no default. — pipelines.md § HITL helpers -
Structured input collection (
input_request) — blueprint and pipeline entry points collect schema-validated input from a human before anything runs.- Pre-run workflow forms — an input-less
submitFreshagainst a blueprint declaringinputSchemaopens the card and returns{ kind: 'input_requested' }with no run row created; the response re-enterssubmitFreshviaenqueueWorkflowFromInput - Pre-run pipeline forms — a workflow-primitive scenario with
inputSchemaintercepts Telegram/runand triggers the Restate ingress only after the form is filled (enqueuePipelineFromInput,tryOpenPipelinePreRunInput) - Mid-run pipeline input —
requestInput()inside a handler, resolved through the awakeable InputSchemaTranslator— turns a blueprintinputSchemainto a prompt and parses a free-text (or voice) reply back into structured fields, both derived from theZodFieldMeta[]introspectJsonSchemagives the dashboard input drawer- No TTL by design — the single-responder
workflow.input_requestcell coalesces re-triggers indefinitely so exactly one card lives until answered or cancelled
- Pre-run workflow forms — an input-less
-
Interception — take the wheel mid-run — a dashboard user opens a live chat with a workflow agent, steers it with ordinary
agent.sendturns, then hands it back;releaseandcancelboth re-dispatch a continuation turn and resume the run.- The pending row IS the session marker — a partial unique index on
hitl_interactions(agentId) WHERE kind='interception' AND status='pending'enforces single-active at the DB level - Resume choke point —
submitResumerejects every user-initiated resume withRUN_INTERCEPTEDwhile an interception row is pending, so no question/grant wake re-drives the run mid-session - Session inherits the intercepted task turn’s auth — the interceptor’s turns and the release continuation turn keep the run’s scenario-scoped grants (no
missing_grantmid-task); an agent parkedawaiting_inputon a question is interceptable (the main case), and because the pause is COOPERATIVE, Release is admitted only from a resumable-or-terminal run
- The pending row IS the session marker — a partial unique index on
-
Chat supersede for pending questions — a plain (non-reply) chat message to an agent parked on a
questioncard cancels that card and wakes the agent with the supersede reason in its resume prompt; card replies still answer the card, andgrant_requestcards are never dissolved by unrelated chatter. -
Custom grant flow (Telegram) — the “✏️ Custom…” button on a grant card opens a three-stage flow (init → LLM-parsed text/voice reply → confirm/change/cancel) that overrides the default TTL and call budget via
customOverrides: { ttlHours, maxCalls }, routed throughInputRouterso voice replies work.- Duration rows —
4h12h1d2d/3d1w2w30dunder Approve/Deny: a one-tap full approve at an explicit lifetime (samecustomOverrides.ttlHoursshape, no text round-trip). Floor isMIN_DECLARED_GRANT_TTL_MS, ceilingMAX_GRANT_TTL_HOURS, so no button can mint a grant the next coverage probe can’t see or the response schema rejects; the resolved card’s verdict names the lifetime it bought
- Duration rows —
-
Durability — terminal state is not dispatched — the gateway commits the human’s decision first and remembers whether the consequences ran, so no crash window loses an answer or double-applies one.
- Two-phase dispatch / outbox —
status(pending → resolved|cancelled) anddispatched_at(null → ts) are independent monotonic axes; a failed dispatch bumpsdispatch_attempts+last_dispatch_errorand stays in the undispatched pool - Apply/unblock split — a
HandlerCell’s fallibleapplyResponse(today onlygrants.issue) runs in try/catch and the mandatoryunblockOnResponsealways frees the waiter, degrading per producer (GRANT_ISSUE_FAILED_PROMPT/grants_issue_failed/ resolve-anyway) - Reconciliation loop —
hitl-reconcileBullMQ job scheduler (60 s, scheduler-produced,concurrency=1worker) releases delegated questions whose parent head start is over (releaseDue), then drains terminal-undispatched rows older than 30 s, backed by the partial indexhitl_undispatched_idx - Opt-in TTL + sweep —
expiresAtonrequest();cancelExpired()routes overdue rows through the normalcancel()lifecycle with a'expired:'reason prefix so cards finalize as⌛ Expired - First-writer-wins + dispatch replay — an already-resolved row with a matching payload is returned for replay, a differing payload is an idempotent no-op, a fully dispatched row is a silent no-op (
responsesEqual) - Barrier defer (soft) — a responder who answers before the agent VO commits
resumeAwakeableIdtriggersHitlError.barrierNotReady, which is a defer (nomarkDispatched, no attempt bump, no caller-visible failure) re-driven by theagent:input_requestlifecycle event - Eager validation — unknown producer, per-producer context invariants, unsupported
(producer, kind), and unregisteredgrant_requestcapabilities all fail before any DB write - Boot backfill for card delivery —
HitlNotifier.start()re-drives pending rows withdispatch_card IS NULL(a question still held for its parent excluded — nobody was asked yet) through the same render path, closing thehitl:requestedpub/sub drop window across a bot restart - Guarded task-run CAS on resume —
enqueueAndResumeflips the owningtask_runawaiting_input → pendingonly when the run holds no live attempt, reporting three-way readiness (task_dead/not_parked→ soft defer /ready) - Retention sweep — amortized prune (5 % of
request()calls) keeping the newest 5000 rows plus every pending row and every row referenced bygrants.hitl_id
- Two-phase dispatch / outbox —
-
Cell-level request coalescing — a
HandlerCellopting intodedupjoins concurrent same-key requests onto one pending row via the partial unique indexhitl_dedup_active_idx, so N producers show a person one card.- Grant fan-out —
stableGrantKey = [subjectId, scopeKind, scopeId, sortedCaps](producer deliberately absent) with a sharedgrantRequestCellthat iteratesinteraction.subscribersand wakes each by its own producer - Single-responder input —
stableInputRequestKey = [subjectId, scenarioId], arity-disjoint from the grant key; itssubscribersarray is intentionally unread so one card enqueues exactly one workflow - Per-subscriber wake idempotency — every wake job’s dedup key (BullMQ
jobId, RestateidempotencyKey) must include the subscriber id; first-writer wins onexpiresAt
- Grant fan-out —
-
Agent-driven HITL triage — the
hitl.*engine tools let an agent observe and resolve interactions through the same CAS + dispatch + event path as Telegram and the dashboard, with no parallel resolve surface. They are engine-api ops: the dashboard’s inbox binds the same ops, so a page and a tool answer with one guard set and one wording, and each records its caller (the dashboard, the calling agent, the MCP session) as the responder.- The request row (since 1.24.0) — every row says
requestStatus(pending · answered · cancelled),requestKind(question · permission · approval · input · sign_in · admission—REQUEST_KINDS),risk(a grant’s riskiest capability under the brain’sengine-capabilities.json; an approvalmoderate, the restsafe),allows(the verdicts it takes now:approve · deny · answer · edit · reply · cancel),bulkableandexpiry({ at, consequence? }, the producer’s word for what an expiry means). An interception is not a request: never listed hitl_list—hitl:read, read-only; filters bystatus,kind,producer(each a list),conversationId,agentId,runId(any execution: a workflow or pipeline run’s own requests and every one a turn inside it raised — a tick’s or fire’s agent’s during that tick or fire only),responder,capability,ids,q(id, title, message, questions, reason, capabilities, the asking agent’s name, its run’s slug or title, its scenario’s name),personFacing,stuck(decided, never delivered),closesWithinHours,feature/entity, a window (defaultall; it holds the requests decided in it and never hides a pending one;windowor wall-clockfrom/to+tz, answered back as the resolvedwindow);sort: oldest | newest | expiry | risk | decided(defaultoldestforstatus: ['pending'], elsenewest;decided— newest decided first, closed requests only); pages bycursor(a cursor of another sort, or ofriskacross a capability-policy edit, is refused),limit≤ 200,detail: 'compact' | 'full' | 'count'with kind-aware summaries —fulladds the payload, the response and what each request belongs to (run, plan, agent, task, grant) — andfacets: true(counts per kind, producer, agent, responder, capability and status, the stuck ones and the ones closing soon, each under the other filters); every row says whether it isstuck, a grant’sgrantTerms(the scope approving mints and its life when none is set) and, once closed, itsoutcome(verdict,label— “Approved 2 of 3”, the card’s own action, “Expired” —,approved, the quoted answer or reply,responder)hitl_get(since 1.24.0) — one request whole: the row, what it links to as refs (the person, the session that opened and the one that answered it, its run, scenario, task, thread, planning session, pipeline run and awakeable) and its delivery (attempts, the last error, a timeline,stuck)hitl_summary— what waits on a person per kind, feature and age (pendingAge: under 1 h · 1–4 h · 4–24 h · 1–3 d · over 3 d), the requests opened per bucket, answered per bucket with the median and p90 wait (latency) and who answered (byResponder: a person, the dashboard, the Boss, another agent)hitl_respond—hitl:write, restricted + HITL-gated; flat response object whose fields the request’s kind picks (answer/actionId(+note) orcustomReply/approvedCapabilities+customOverrides/input); a sign-in or an interception is not answered here. A request no longer pending is refused, never answered twice. Approving agrant_requesttakes a person: the gateway refuses every non-person[unauthorized_responder]— the active boss run inside itsapprovableCapsaside (never its own card, never a principal-widesubjectgrant) — and the card stays pending; denying (approvedCapabilities: []) is open to any responderhitl_respond_bulk—hitl:write; up to 50{ hitlId, response }items, each answered as its ownhitl_respond(one failure does not stop the rest) →{ answered, skipped, failed }; a pending request that is notbulkableis skipped and named inskippedwith why — an admission (answered in its chat), a question, an input, a sign-in, an approval with no one-tap pickhitl_cancel—hitl:write, restricted + HITL-gated; reason bounded to 500 chars; a refusal leads with the gateway’s code ([already_resolved]-style discriminators) so agents branch without string-parsing. The caller is passed as the canceller, so a producer that keeps its cards for a person (the loop’s decide and retry cards, a rating card) refuses a non-person[unauthorized_responder]and the card stays pending — the same checkhitl_respondalready ranagent_answer/agent_escalate— the parent’s side of a sub-agent’s question (agent:execute, descendants only): answer it (the answer prompt is wrapped as Agent’s answer… rather than User’s answer…), or forward it to the person now with a note; grant requests are not answerable there — a person approves them
- The request row (since 1.24.0) — every row says
-
Boss agent — a meta-agent that works the HITL queue — a person starts a time-boxed boss run whose
BossRunConfigfixes every security knob:expireAt(must be future),hitl.forwardingDelayMshead-start for the human,hitl.allowedKinds,initialCaps, andapprovableCaps.boss_status/boss_start/boss_stop(since 1.24.0) — Boss mode as engine ops, the dashboard’s and an agent’s: the active run and the MANIFESTboss:defaults (boss:read), start on a fullBossRunConfig(dryRunchecks it) and stop (boss:execute— restricted, HITL, granted to no role: an agent starting Boss mode asks a person first)BossHitlBridge— mirrorshitl:requestedinto a delayed per-run wake-up enqueued on the boss agent, collapsing bursts onto one deferred invocation; the human always gets first refusal via the delay- Run terms in the wake-up — the bridge renders
expireAt,allowedKinds,initialCapsandapprovableCapsinto the turn body, so the boss reads its own authority instead of discovering it from a failedhitl_respond; deliberately absolute (not “expires in 47m”) because the idempotency key collapses a burst onto one deferred invocation - Brain-tunable run defaults —
constitution/MANIFEST.yaml§boss:(ttlMinutes,hitl.*,initialCaps,approvableCaps) seeds the start form over engine.default()s; an omitted cap list still falls back to preselecting every restricted capability, a declared[]starts the picker empty - Brain-authored prompt —
constitution/Agents/system/role-boss.mdoverrides the engine’s copy whole,modelRefincluded (nothing is pinned byboss_start); brain-facing spec indocs/brain-dev/boss-mode.md - Loop guard — a boss can never respond to or cancel an interaction it produced itself, enforced in
hitl_respond/hitl_respond_bulk/hitl_cancel, not just at enqueue - Approvable-caps guard — every
approvedCapabilitiesentry must be in the run’sapprovableCapsallowlist; the list is the security boundary, the role prompt is only documentation - Expiry + run summary —
bossExpireScheduler/bossExpireWorkerend the run (user_stopped/expired/crashed) and emitboss:run.expiredwith aBossRunSummary(duration, turns, tools by count, HITL by kind and status)
-
HITL events on the per-agent output stream —
hitl_request/hitl_responseenvelopes carrying the full row snapshot are written toagent:{agentId}:outputalongside the EventBus emit, so live agent views upsert the card in place byhitlIdinstead of refetching; pipeline HITL (agentId === null) skips the stream write, and stream failures never fail the mutation. -
Cross-surface card synchronisation — every consumer subscribes to the same two events, so a resolve on the dashboard finalizes the Telegram card (verdict footer, keyboard cleared) and vice versa; the notifier handles both race orderings (response before card sent, already-terminal at render time).
-
Chat stream rebind after an agent resume —
AgentStreamRebinderre-binds the chat presenter onhitl:respondedfor(resolved, producer='agent', kind ∈ {question, grant_request})when the resumed agent is still the surface’s active chat agent, gated so a batch of N workflow grant approvals cannot clobber the live chat session. -
Headless HITL consumer for tests —
TestHitlChannelsubscribes to both events and exposeswaitForRequest(predicate)/respond(), backing 21*.integration.tssuites covering CAS, per-cell side effects, dedup, reconciliation and stream broadcast.
Policy & Security
-
Capability model — one vocabulary for “what may this agent do” — a capability is a
domain:capabilitystring carrying{ risk, hitl, description }, declared by tools (providesCapabilities) and by plan tasks (TaskSpec.requiredCapabilities).- Three layers, merged at runtime — hardcoded
ENGINE_CAPABILITIES, brain overrides inconstitution/engine-capabilities.json(risk/hitl/description only), and brain-new capabilities inconstitution/project-capabilities.json; unknown or colliding keys warn and skip - Engine domains —
runtime,artifact,agent,queue,governance,feature,skill,db,event,brain,infrastructure,hitl,schedule,planning,workflow,loop,episode,pipeline,integration,research,email,drive,sheets,calendar,tasks,messaging,contact,access,telegram,notification,docs,design,media,writing,engineering,file,memory - Risk levels —
safe < moderate < dangerous < restricted(compareRisk/maxRisk/deriveRisk), informational for UI and logging; real authorization is thehitlflag hitl: truegate — engine defaults areemail:send,messaging:send,access:admin,access:widen,telegram:invoke,drive:delete,infrastructure:discover,hitl:write,memory:delete,loop:analyze,loop:auto_accept; a brain may flip any of themADMIN_ONLY_CAPABILITIES— an engine-owned closed set (todayloop:auto_accept) thatGrantService.issuerejects from every source butadmin/admin_extendand that prompt builders strip from the planner’s vocabulary; deliberately not aCapabilityMetafield so a copied-forward brain file cannot silently un-restrict it- Admissibility rule for
notification:send— a send-class capability may behitl: falseonly when the destination is never a free-form argument (no destination at all, or a closed enum of owner-declared channels); anything taking a raw chat id ismessaging:sendterritory - Package API —
getCapabilities,getCapabilityMeta,getCapabilitiesByDomain,isValidCapability,deriveHitl,requiresApproval,getCapabilityRisk,parseCapabilityString(s),extractCapabilities,getCapabilitiesForPrompt,getEnrichedCapabilitiesForPrompt,classifyDeclaredCapabilities,getCapabilityProvenance/diffCapabilities,resetCapabilitiesCache/reloadCapabilities - Agents and the dashboard write the values —
capability_set_policy/capability_delete_policy/capability_write_file(governance:write, restricted, approval by default — a write can remove any gate, its own included) edit risk / hitl / description in both files; the dashboard’s Permissions page (/permissions) calls the same ops.capability_set_policy/capability_delete_policyanswerconsequenceson the dry run and the commit alike (since 1.24.0): each changed field before → after with its direction, andwidening: truewhen the write turns an approval off or lowers the risk — a reset to an engine default looser than the brain’s value included. Dry run →expectedSha→ compare-and-swap on the file’s sha (conflictwhen it moved), a file that would not load (a renamed key included) isinvalid; the writing process reloads its catalogue, the others pick the change up through their content watchers, brain-sync commits it. Aninvalidfile is refused for a value edit, never rewritten — same rule as regen; only a whole-filecapability_write_filereplaces it. One audit row per write omnislash regensync —ensureEngineCapabilities(a regen materializer, soinit/regen/upgradeall produce it) adds new engine caps, drops removed ones, and preserves brain overrides; a MISSING file is written with engine defaults, while a policy-shaped-but-invalid one is left byte-identical and reportedconverged: false(never silently overwritten — see packages/capabilities/CLAUDE.md).omnislash doctorreports the state read-only and writes nothing
- Three layers, merged at runtime — hardcoded
-
Grant model — one grant, exactly one durable scope —
omnislash.capability_grantsrows carry NOT NULL(scope_kind, scope_id), atext[]of capabilities, optionalmax_calls, a TTL and an audit link to the approving interaction; a grant attached to nothing is unrepresentable.- Six scope kinds —
scenario(slug),agent(agents.id),heartbeat(definition slug),hook(definition id),role(role slug),subject(SubjectId);heartbeatandhookexist so one approve survives a row-per-tick / row-per-fire agent;rolecovers every turn run as that role and is never minted from a request — onlygrant_createissues one (a person on the dashboard, or an agent with a person’s yes) - Visibility = scope equality —
grantScopesOf(auth)derives one entry per populated axis andbuildGrantVisibilityWhereORs them into index lookups; an empty set compiles tofalse(deny everything) grantTargetOfmint precedence —heartbeat > scenario > hook > agent, neverrole, neversubject, so a runtime request can never escalate itself to principal-wide authority- Per-kind default TTL — 24 h for
scenario/agent, 30 days forheartbeat/hook/role/subject, clamped into[30 s, 365 d], and into[30 s, 720 h]for an agent’sgrant_create/grant_extend(sourcecommand); a card’scustomOverrides.ttlHoursoverrides either way - Lifecycle —
active → revoked | expired | exhausted;consumeincrements atomically underWHERE used_calls < max_calls, an expired row staysexpired(status is derived, never swept),revokekeeps the row, and probabilistic GC (5 %) reaps rows older than 30 days - Nothing cascades — deleting an agent row leaves its
agent-scoped grants as unreachable rows until retention GC; a grant is never re-pointed - Events —
grant:createdon every successful insert (HITL approve included, carryingsource) andgrant:revoked
- Six scope kinds —
-
GrantService— one read primitive, one write path —authorize/computeCoverageanswer coverage,issueis the only insert, andconsume/revoke/listActivecover the rest.requiresApprovalfiltered inside the read path — callers pass raw capability lists; ungated names are dropped once, in one place, so a card can never ask a human to approvefs:readIssueSourcediscriminates replay safety —hitl_approvalrequireshitlIdand writesINSERT … ON CONFLICT (hitl_id) DO NOTHING + SELECT;admin/declaration/bootstrapare plain inserts;admin_extendrevokes and inserts in one transaction- Boundary validation —
issuere-parses the scope throughGrantScopeSchemaand every capability throughparseCapabilityString, because both cross tRPC input and HITL JSONB before landing
-
provisionGrants— read-then-ask orchestrator — one primitive replacing per-producer coverage+request boilerplate: probecomputeCoverage, return'covered', else open agrant_requestand return{ status: 'pending', hitlId }; coalescing is the gateway’s job, and it never mutates run state. -
issueDeclaredGrants— pre-authorize an automation with agrants:block — a*.hook.md/*.heartbeat.md/*.scenario.mddeclarescapabilities+ optionalttlSeconds/maxCallsin frontmatter and the engine mints exactly what an approve in the same context would mint, with no card.- Five materialization seams — hook fire (
HookRunner), heartbeat tick (heartbeatWorker), workflow run (materializeDeclaredGrants, at every run re-entry, before the gate), schedule-time gate (scheduleWorkflow), and every pipeline invocation (handler-instrument, so a pipeline that never callsrequestGrantis still covered) - Same scope, capped lifetime — scope from
grantTargetOf(auth);ttlSecondsbounded to[MIN_DECLARED_GRANT_TTL_MS = 4 h, MAX_GRANT_TTL_HOURS = 720 h]at file load, defaultDECLARED_GRANT_TTL_MS = 24 has a refresh cadence - Classification before write —
classifyDeclaredCapabilitiesdrops unknown, ungated and admin-only names per entry so their grantable siblings still get a row; never throws — a bad entry degrades tomissing_grantat the tool gate - One engine-wide coverage window — every probe uses
DEFAULT_GRANT_DEDUP_MIN_REMAINING_TTL_MS(2 h), so a 1-minute heartbeat writes ~1 row per 22 h rather than 1440/day - Two standing-scope consequences —
maxCallson aheartbeat/scenarioscope is a per-refresh budget, not a total; and a dashboard revoke does not stick while the declaration is live (the file is the kill switch) - Visibility without a card —
grant:createdcarriessource: 'declaration', andomnislash doctor→ Declared grants inventories every block and flags admin-only / no-approval-needed capabilities and cross-entry duplicates (an unknown name isCapability names’) - Trust precondition — the authority of a declaration rests on the constitution file being human-reviewed; a brain whose agents can commit their own
constitution/has delegated capability issuance to them
- Five materialization seams — hook fire (
-
Two-level authorization through one primitive — plan-level (
checkGrantsin theworkflow-runhandler) and tool-level (ToolsGateway) both callgrants.authorize, so the two layers cannot disagree on visibility, exhaustion or the slack window.- Plan level — union of
requiredCapabilitiesacross task specs, missing →provisionGrants→ run parked inawaiting_approval; a subsequent approve resumes, any partial or denial fails the run (rehab is binary) - Tool level — fail-fast, never blocking:
deriveHitl(providesCapabilities)→ require a non-emptygrantScopesOf(auth)(elsemissing_auth_context) →authorize→ on successconsume(grantIds), on failure a structuredmissing_grantpayload - Auth-derived remediation — the failure sentence names a path the caller can actually take: agent →
hitl_request_grant, scenario-only pipeline step →requestGrant(ctx, …), bare principal (MCP bearer door) →hitl_request_grantfor a standing subject grant, none of those → ask an administrator - Runtime escape hatch —
hitl_request_grantis offered on every session andalwaysLoad: trueputs its schema on turn 1; it asks for a gated capability aftermissing_grantand for one the role never listed (a refused tool, a missing shell) alike — and refuses, before any card, a request approving would not change: a capability whose every toolengine-tools.jsonswitches off (no grant turns a tool on), or, from an MCP session, one that needs no approval (a subject grant authorizes gated calls, never adds a tool) - A grant widens the agent it covers — each turn segment’s effective capabilities are the role’s pinned
capabilities:∪ every capability on a live grant for the agent, its heartbeat, hook, scenario or role (GrantService.grantedCapabilities, same liveness as the gate; a principal’s standingsubjectgrant authorizes but never widens), read by the MCP tool list, the API tool set, the Claude built-ins and the prompt; an approval’s tools appear on the agent’s next step and leave when the grant expires or is revoked. Ungated capabilities on a grant widen without ever consumingmaxCalls - Capabilities prompt module — every agent not holding
'*'whose ask a person can answer (not unattended, not a loop round or replay) is told what it could still ask for (catalog minus held, minus admin-only, minusmemory:*without a memory block; on Claude, the built-in groups it lacks) and to ask rather than make a person do the work;with-capabilities.md, overridable, priced on/contextas Requestable capabilities - Partial approval semantics — the
agentproducer degrades gracefully (issue the approved subset + partial prompt),schedule_*issues with nothing to resume,pipelineresolves the awakeable with the subset,workflowrehab fails the run
- Plan level — union of
-
Grant producers, catalogued —
schedule_manual,schedule_cron,workflowrehab,agentruntime escape andpipelinereach a grant through a human approve;declaration,bootstrap(boss start) andadmin/admin_extend(a person on the dashboard) bypass HITL entirely;command(since 1.24.0) isgrant_create/grant_extendcalled by anyone but the dashboard — an agent’s call waits on thegovernance:writecard. Onlyadmin/admin_extendcan issue an admin-only capability;commandnever issuesgovernance:write(the gate on its own card); a Boss run’s own grant is refused togrant_extend/grant_revoke; asubjectscope only those and a HITL approve whose card names it. -
Grant administration — the
grant.*engine ops (toolsgrant_*; readsgovernance:read, writesgovernance:write— restricted, HITL, granted to no role by default).grant_list(since 1.23.0) reads the standing grants: filtered by computed statusactive|expired|revoked|exhausted, by oneentity, byfeature(the grants of the entities it ships), by one session (agentId) and (since 1.24.0) bycapabilityandscopeKind, sortednewest/ending/used, keyset-paged, each row with its derived status and its holder’s name — the agent’s for an agent scope and (since 1.25.0) the scenario’s for a scenario scope —, plus facets that each count under the other filters:countsper status,byScopeKindper holder kind andexpiringSoon(active, lapsing within the hour). Since 1.24.0:grant_getis one grant in full — the request that minted it (with its status), its holder as a ref (the entity;session:for an agent scope;person:for a person’s) andreplacedByonce it was extended;grant_list_declarationslists everygrants:entry of a scenario, heartbeat or hook (inline hooks included) with the row it minted;grant_createissues a grant for any holder (dryRunanswers the row and what it widens — the capabilities that wait for approval today, any admin-only one),grant_extendreplaces a live, lapsed or used-up grant atomically with more time or calls (a revoked one stays ended),grant_revokeends one (id) or up to 50 (ids), the record kept. An agent (sourcecommand) can never grant an admin-only capability or a person’s whole principal and gives at most 720 h; the dashboard (sourceadmin) can. Each write is one action-log row against its holder or its grant. -
Standing delegation without an agent-visible ask —
loop:auto_acceptis a capability no tool provides: the loop reads it withgrants.authorizeand, absent a grant, always degrades to the human Accept card; being admin-only is what keeps an agent from requesting removal of its own oversight. -
Prompt-injection defense on external content —
wrapExternalContent/wrapWebContentfence untrusted text (emails, web pages, webhook bodies, tool results markedexternalContent) with cryptographic boundary markers, strip invisible characters, fold fullwidth/homoglyph angle brackets that could spoof a marker, and logdetectSuspiciousPatternshits without blocking the content. -
SSRF protection on outbound fetches — a guarded undici dispatcher blocks private/link-local IPs, validates DNS resolution and re-checks across redirects on
web_fetch, remote-media fetches and outbound dispatch deliveries. -
Agent filesystem sandboxing — path containment and repo-boundary enforcement for file operations, with bwrap-based isolation for jailed dispatch (agent-isolation ladder is the agent-runtime domain).
-
Secrets stay out of committed config —
omnislash.config.tsnever importsenv; secrets and private values are read throughenv.*inside packages/apps, so the config file is public committed brain code.- Per-skill env is filtered —
collectSkillEnvVarsgathers declaredrequires.envvalues for the child process, andisDangerousEnvVarNameblocksPATH/LD_PRELOAD/NODE_OPTIONS/HOMEand friends into a reportedblocked[] - Token redaction — a token embedded in a skill source URL is redacted from every error message before it can reach a CLI or CI log
- Chat-bound error scrub —
scrubForChat(@omnislash-ai/utils/security) rewrites URL credentials, auth headers, bearer tokens,sk-/gh*_/xox*/AIzakeys and JWTs, redacts every whitespace-freeprocess.envvalue of 12+ chars as[redacted:VAR_NAME], then truncates at 1000 chars - Admin surfaces are token-gated —
MCP_ADMIN_TOKEN(≥32 chars, SHA-256 + constant-time compare) gates/admin/sessions, andartifact_publishrefuses private publishing unlessADMIN_USER+ADMIN_PASS_HASHare configured
- Per-skill env is filtered —
-
Agent DB access with schema isolation —
db_schema/db_query/db_executelet agents work with the brain database while PG-levelsearch_path=publickeeps engine tables (omnislash.*) invisible without explicit qualification. Introspection is the deliberate exception:db_schemareads the catalog acrosspublic+omnislashand tags every entry with its schema, because an agent that cannot see the engine table cannot learn to qualify it — the isolation is about what resolves unqualified, not about what is knowable.- A table states what its rows are, not only what shape they have —
db_schemacarries each relation’s own DB comment through asnote.omnislash.agentsuses it to say the one thing its column list cannot: it is a sliding retention WINDOW, not a journal. The sweep runs in the pipelines service (soservices.pipelines: falsestops it) and removes rows in TWO phases:hb:%/hook:%rows are hard-deleted outright once idle pastagentRetention.ttlHours, while transient rows (run_in <> 'live',thread:%, or an agent-spawned row whose parent is gone or tombstoned) are first TOMBSTONED and hard-deleted one window later — so a tombstone is on disk for up to TWICE the window and every “live agents” query must saydeleted_at IS NULL(the column carries that as its own comment). Only live non-thread chat agents, the loop’s own agents and agents holding a resource watch outlive the window at all. Rows older than it do not exist, so “first occurrence”, “only case in N days” and per-beat run coverage over a wider window are all unanswerable from this table however confidently the SQL returns. Fingerprint history belongs to a durable source (a memory line, Loki, BullMQ). The prose owner named in that comment ispackages/infra/src/queries/agents.ts(softDeleteIdleTransientAgents,findExpiredAgentIds,hardDeleteAgentsByIds) — drizzle-kit models noCOMMENT ON, so the two move together by hand.
- A table states what its rows are, not only what shape they have —
-
Chat access is governed like any other write — who may use a bot is
constitution/access.yaml, edited by agents throughaccess_update(access:write, no approval: it refuses any change that widens access) andaccess_widen(access:widen,hitl: true: admitting people, serving or opening a chat, granting members commands or capabilities — the owner approves the exact edits), read throughaccess_overview/access_identities/access_explain(access:read);owneris a global tier no code can mint, and HITL approvals stay owner-only wherever the card hangs — chat-access.md -
Infrastructure endpoints behind a grant — internal service URLs (Prometheus, Loki, Grafana, DB, Redis, Bull Board, Restate UI) are only reachable through
infrastructure_endpoints, whoseinfrastructure:discovercapability isrestricted+hitl: true; the public boundary (endpoint_list/endpoint_get,runtime:read) never names an internal host or port.
Cascade Planner
- Staged planning pipeline — planning is split into up to 12 focused LLM stages instead of one monolithic call; each stage gets an agent, reads only the files it needs, emits structured JSON, merges into
PlanningStateand rewritesblueprint-state.jsonfor the next stage - Stage inventory
- 1 — Analysis — extracts
slug,goal,successCriteria,assumptions,ambiguities,priority,researchNeededand an optionalinputSchema; an optionalstateOverviewis appended toscenario.md - 2 — Clarification (interactive, optional) — up to 4 questions in 2 rounds, asked only when the answer would change task structure, then rewrites the analysis so downstream stages see only the post-clarification version
- 2.5 — Research (conditional) — codebase/filesystem/DB enumeration producing
summary,enumerationsandkeyFacts, run with elevated capabilities (agentCapabilities: ['*']); included always / never / runtime-skipped byPlanningOptions.research, with a stub written on skip so downstreaminputFilesalways find a consistent shape - 3 — Deliverable — decides description, filename, format and schema; scenario config can pin
formatas a HARD CONSTRAINT, andstorageis injected by assembly rather than chosen by the LLM - 4 — Decomposition / Task Extraction — Cascade decomposition into primitive tasks, or direct extraction from scenario text when
decompose: false - 5 — Decomposition Review (interactive, optional) — the user confirms or revises the task list
- 6 — Capability Mapping — assigns
domain:capabilitystrings plus a rationale per task - 7 — Task Refinement — adds
definitionOfDoneand theoptionalflag per task - 8 — Context Group Assignment — assigns
contextGroup(or independence) andcompactAfterper task - 9 — Phase Organization — organizes tasks into sequential phases; the merge enforces that every task appears in some phase or throws
RetryablePlannerError - 10 — Artifact Planning — assigns
outputArtifactsfilenames for cross-group data flow (deliberately exempt from the coverage check) - 11 — Final Review (self-correction) — a separate reviewer model reads a pre-assembled blueprint and may correct only
contextGroup,compactAfterandoutputArtifacts— no task creation, deletion or other field edits
- 1 — Analysis — extracts
- Progressive task enrichment — tasks start as
{id, title, description, recommendedSkills?}from stage 4 and accumulate capabilities → definition of done → context groups → artifacts → final corrections, withassertTaskCoverage()guarding every id-keyed enrichment stage - Two-phase execution for interactive stages — an interacting turn runs with no
outputSchemaso the model can callAskUserQuestionwithout JSON-mode bias, then auto-transitions to a producing turn that emits the structured output - Auto-recovery — every stage but clarification carries a
validate(); a failed validation firesonRecoveryAttempt, clears the stream namespace, sends a correction prompt and re-runs the same agent once, tracking the outcome on the session - Retry with progress preservation — a
RetryablePlannerErrorsetsretryPendingand reports;retryLastStage()re-runs from the failedstageIndexwith all prior stage files intact - Session persistence —
PlanningSessionowns all mutable state and persists to Redis (planner:session:<id>+planner:meta:<id>, 24h TTL refreshed on save) after every stage transition, so a worker restart resumes viaPlanningSession.fromSnapshot(); the snapshot shape is Zod-validated on load - Configurable pipeline shape —
clarification,decompose,reviewandresearchflags select which stages are built, changing pipeline length and the number of agents created - Agent groups — stages sharing an
agentGroupshare one agent (capability mapping + task refinement shareenrich); all other stages get a fresh agent with rolesystem:plannerand default capabilities['file:read', 'research:read'] - Model selection & overrides — resolution order
stage.modelRef > session modelRef > planner role modelRef > system:general; final review pins the reviewer model- Session model override —
planning.modelRefoverrides the planner agent model for a single planning session from chat, without changing defaults
- Session model override —
- File-based stage IO — each run gets a temp
/tmp/planner-XXXXX/workdir holdingscenario.md,blueprint-state.json, the numbered per-stage outputs andassembled-blueprint.json; preserved across retries, cleaned up on completion, with opportunistic sweeping ofplanner-*dirs older than 2 hours - Blueprint assembly & validation —
assembleBlueprint()hydratesPlanningStateinto a fullBlueprint(injecting storage, execution and loop config), thenvalidateBlueprint()runs Zod plus business rules (uniquetask-Nids, non-empty context groups, capability-string refinement, deliverable filename present in artifacts, traversal-safe artifact filenames) - One-shot planner — a single prompt → single structured output → Blueprint path for simple scenarios, with retry + recovery resilience (up to 2 retries × 2 recoveries) and no stages, Redis state, interactive mode or resume
- Planner registry & introspection —
createPlanner(method, deps)plusisValidPlannerMethod()/listPlannerMethods()back the scenario method picker;PLANNER_EXECUTION_MODEsplits legacy in-process methods from Restate-durable ones so a new durable planner auto-routes - Planning cancellation — an
AbortSignalper session threaded throughrunAllStages(checked at stage boundaries) and into the in-flightagent.call, driven cross-process byplanner:cancel.requested→planController.cancel(sessionId); the planning queue runsattempts: 1so BullMQ cannot resurrect a cancelled session - Live planning cards —
PlanningChannelreducesplanner:session.started/stage.started/stage.completed/blueprint.ready/session.failed/session.cancelledinto a Redisplanning:session.snapshot(schema-validated on read, active vs terminal TTLs), which chat and dashboard render as a live progress card with the current stage, questions being asked, and the assembling plan. Theplanning.*engine ops (since 1.23.0) read and stop them:planning.list({ status: 'active' | 'recent', id? })(planning:read— the live sessions, the ones that ended in the last 24 h, or one session byidwhichever it is),planning.cancel({ id })(planning:write, recorded against theplanningsession — asks the planning worker throughplanner:cancel.requested; an ended session is refused) andplanning.list_methods(planning:read— the methods a scenario’splanner.methodnames) - Planner HITL — planner-stage questions ride the unified HITL gateway with
context.planningSessionId; responding re-enqueues the stage agent with the answer, and multi-round questions work because each respond is a fresh enqueue against the same CLI session - Free-text execution overrides — plain-language tweaks to how a plan will run (priority, PR, timeout, model ref) are LLM-parsed into a typed
ScenarioExecutionOverridespayload, validated (parseAgentReffor the model ref), echoed back for confirmation and applied at launch - Prompt corpus — every stage prompt is a brain file under
constitution/Planner/Methods/Cascade/Stages/, with a separate one-shot prompt directory, so a brain can override any stage’s instructions
HTN Planner
- Real hierarchical task-network planner — a third planner method (
htn) doing genuine recursive, data-dependent decomposition into typed nodes, run as the durable Restate serviceplanning-runinapps/pipelines; it produces the sameBlueprintcontract as the legacy planners, so downstream execution is unchanged - Selection — set
method: htnin the scenario’s planning options; the planning worker routes Restate-planner methods totriggerPipeline('planning-run', …)whilecascade/one-shotstay on the in-process controller - Node model — five concepts, nothing more
- Typed nodes — every node is
compound(must be decomposed) orprimitive(a directly-executableBlueprintTask) - Method with rationale — a compound node records the rationale of the decomposition it chose plus the
excludedrationales already tried and rejected - Recursion to primitive — children replace a compound node until every leaf is primitive; the loop terminates on that invariant, not on the model declaring itself done
- State-gated applicability — each decompose call receives the threaded plan state (ancestor goals + already-decided preceding-sibling specs + the frame), so the same goal decomposes differently under different accumulated context
- Subtree-local backtracking — a
reopenre-decomposes exactly one parent’s subtree with the failed method excluded; siblings and ancestors are untouched
- Typed nodes — every node is
- Two LLM steps only — a Frame step (
slug,goal,assumptions,questionsForHuman,successCriteria,deliverable, optionalinputSchema,rootGoal) and one Decompose call per node; capability mapping, DoD and artifacts are emitted by the primitive-emission step itself - Deterministic tree → Blueprint linearization — series-parallel composition maps the tree onto the 2-level Blueprint with no LLM phase stage: a primitive leaf becomes one task, a
sequentialnode concatenates its children’s phases, aparallelnode merges them by index - Bounded by construction —
maxDepthandmaxNodesforce a node primitive at the bound and a per-nodereopencap stops a pathological decomposer, so the loop always terminates - Durable, journaled decomposition — every LLM call is its own
runAgentdurable step, so a crash resumes from the journal without re-issuing completed decompositions; node ids are derived from tree position (never random) so the tree rebuilds deterministically on replay - Inline HITL clarification — a node can suspend the same invocation on a Restate awakeable via
askQuestionand resume when the user answers- Explicit routing — engine built-ins are served raw with no handler ALS, so the handler reads
surface/subjectIdoff its durable input and passes them explicitly, keeping the card in the originating chat instead of the owner fallback - Answers change the plan — the answer is folded back into the scenario text and
runFramere-runs, so the resolved ambiguity actually reshapes the plan rather than merely unblocking it - Bounded multi-round —
MAX_CLARIFICATION_ROUNDScaps the loop; past the cap planning proceeds with whatever frame survived - TTL-bounded card — a journaled
expiresAt(CLARIFICATION_TTL_MS, computed once insidectx.runfor replay determinism) lets the reconcile worker sweep an unanswered card to terminalfailedwith no person present
- Explicit routing — engine built-ins are served raw with no handler ALS, so the handler reads
- Real cancellation — the method-agnostic
planner:cancel.requestedevent is honored on both paths; forhtnthe worker resolves the live invocation fromsys_invocationbyidempotency-key = planSessionIdand issues a graceful admin cancel, the handler catchesCancelledErrorand emitsplanner:session.cancelled, andcancelPendingClarificationsweeps the pending card so a late answer no-ops against its CAS - Self-emitted, journaled observability — engine built-ins bypass pipeline auto-instrumentation, so the handler emits
planner:session.started,planner:stage.started/.completedper decomposition node,planner:blueprint.ready,planner:session.failedandplanner:session.cancelleditself, each insidectx.runso a replay cannot re-fire and corrupt the order-sensitivePlanningChannelsnapshot - Structured-output wire encoding — the decompose schema (
HtnDecomposeSchema) is a flat rootz.object()with akindenum discriminator and.nullable()branch fields, because az.discriminatedUnionrenders as a root-leveloneOfthat both Claude and Codex reject;parseDecomposeResultvalidates the flat shape and narrows it back to the discriminated union, rejecting a branch missing its required field - Brain-owned prompts —
constitution/Planner/Methods/HTN/frame.mdanddecompose.md, mirroring the cascade stage prompts, with a working set shipped in the engine’s dev brain
Workflow System
- Restate-native run engine — a workflow run executes as a
workflow-runRestate Virtual Workflow keyed per attempt (${runId}::a${n}), the single durable owner of the run- Sole writer — the EXCLUSIVE
runhandler is the only writer ofworkflow_runs+workflow_task_runs; the agent VO stays sole writer ofagentsand the run only READSagents.status - Crash-resume is free — a worker/pipelines crash mid-run is Restate journal replay, not a recovery path; every DB write is a journaled
ctx.runstep live_attemptclaim CAS —claimLiveAttempt(runId, attempt, {stealFrom?})admits at most one attempt across distinct attempt keys (Restate’s per-key lock cannot span them); the loser no-ops instead of becoming a second writer- Release-at-seam —
releaseLiveAttemptclears the claim at the singlerunBody()return seam on terminal AND resumable returns, so the next attempt finds a clean slate; a killed attempt’s stale claim is taken over by a value-namedstealFromrecover - Terminal-CAS clamp — the only remaining CAS on normal writes:
transitionRunStatus(ACTIVE_EXECUTION_STATUSES, derived)stops a lagging projection resurrecting a clamped-terminal run
- Sole writer — the EXCLUSIVE
workflowRunClient— typed ingress API — every driver (worker, dashboard, mcp, chat, scheduler, pipelines) reaches the handler through one client keyed on the stablerunIdsubmitFresh(scenarioId, createdBy, runConfig?, opts?)— mintsrunId+ slug and submits attempta1;opts.delayschedules via a Restate timer,opts.idempotencyKeydedups the run row via a partial-unique indexsubmitAdhoc(...)— the fresh attempt of an ad-hoc run: a plan made from an inline brief rides the create input instead of a.blueprint.yml, keyed by its planning sessionsubmitResume(runId)— new attempt from anyRESUMABLE_EXECUTION_STATUSES, re-driving off DB-projected progress (skips completed phases, re-attaches live agents); the single choke point where a pendinginterceptionHITL row rejects withRUN_INTERCEPTEDsubmitRetry(runId)— new attempt fromfailed; the handler’sfailed → runningCAS is the single-winner guard, so concurrent retries run the agent exactly oncerequestStop(runId, reason)— drives the control channel; returns{ acted }so a pause against a no-live-attempt orphan stops reporting a false “Paused”status(runId)— live derive-on-read status of the in-flight attempt,nullwhen none is liveresumeForRecovery(runId, stealFrom?)— deliberately ungated re-drive used only by the deterministic orphan reconcilerenqueueWorkflow*facade — result-shaped adapters (enqueueWorkflowExecution/Resume/Retry/Redrive) over the client for HTTP / tool / dashboard callers
- Ad-hoc runs from an inline brief — a planning job with
source: 'brief'plans the brief with nothing read from or written to the brain; once planned, the plan starts one run with no scenario (scenario_idnull, theexecutionsrow with no entity) that keeps the brief and a title. It is never graded and never feeds the loop — it belongs to no entity. A capability grant it needs is asked by the task agent at its tool gate and approved for that agent only, never as a standing grant for the requester. The plan card says the run started instead of offering a schedule. In this release nothing produces a brief job yet: the bot’s/planstill writes afeatures/adhoc/scenario and shows the schedule card, and moves to briefs in the next release - Phase execution model — tasks are grouped by
phasenumber; phases run sequentially, tasks inside a phase run in parallel- Context groups — a named
contextGroupruns its tasks sequentially on one chained shared agent;contextGroup: nullgives each task its own agent in parallel. Distinct groups and ungrouped tasks all race concurrently - Cross-phase agent chaining — the
agentMapis built once per run, so a named group keeps the same agent session across phases;buildAgentMap()reconstructs it on resume from completed tasks (last completed per group wins) compactAfter— a blueprint task can request agent-context compaction after it completes, before the next task in its group — a compaction turn queued on the group’s shared agent ahead of the next task- Optional tasks —
optional: truefailures don’t fail the phase or the run; the group continues with a fresh agent (the chain breaks at that point) currentPhasepersistence — advanced on phase completion; a resume skips phases below it
- Context groups — a named
- Derived run status — run status is computed from the lifecycle marker + task rows, never stored independently
- Priority order — first match wins: all required tasks
completed(optional ones settled) >cancelclamp (outranks a failed task) > a required taskfailed>pauseclamp (never masks a failure) >awaiting_approval>awaiting_input>running>pending - Journaled lifecycle marker —
run/pause/cancellives in the attempt’s Restate journal state (ctx.set(LIFECYCLE_KEY)), not a column; it clamps the bottom-up rollup so a sibling completing during a pause re-derives topaused awaiting_retryis explicit — set by the handler at the phase aggregate through an ACTIVE-guarded CAS, not derived- PR-finalize hold — a
pullRequest: truerun is held offcompletedwhile its finalize window is open; the hold is required and threaded to every projection, and intent-aware (a pause mid-push projectspaused, never a terminal that would drop the PR)
- Priority order — first match wins: all required tasks
- Model B suspension semantics — every pause /
awaiting_*state RETURNS the attempt instead of holding a durable invocation open; resume is a fresh attempt key that re-reads DB-projected progress, which is what lets a run wait days on a human without pinning a sandbox or a permit - Control channel — pause / cancel / interrupt — a durable resolve-once
controlpromise per attempt, raced inside the phase looppause— sets the lifecycle clamp and returnspausedwith task rows untouched (runningstaysrunning); resume re-attaches live agents offagentIdinterrupt(graceful) — drops control and runs to the next phase boundary, then pauses; it never stops an in-flight agent, and a single-phase run absorbs it intocompletedcancel— lifecycle clamp +stopRunAgents+applyWorkflowCancelterminal-CAS + pending-HITL cancel, emittingworkflow:run.cancelledexactly oncea0clamp attempt — cancelling a non-live (paused / awaiting) run submits a reservedkind:'cancel'attempt on keya0that runs the terminal cascade directly; concurrent cancels dedupe onto it- Agent teardown, two layers —
stopRunAgentsinvocation-cancels every live task agent (and the named PR finalizer) and awaits their honest terminal rows; the agent VO’s run-liveness guard-on-wake independently self-aborts any turn whose owning run is already terminal, covering the startup-transit / backing-off / re-attach windows the scan misses
- Task dispatch & the observe seam — the handler decides per task from the row’s
agentId:completed→ skip, noagentId→ dispatch, boundagentId→ re-attach without a duplicate dispatch- Run-owned awakeable — a dispatched task fires a fire-and-forget VO turn carrying
pipeline.callbackId; the VO resolves the awakeable on terminal AND onawaiting_input, so a HITL pause arrives event-driven - One deterministic race —
observePhaseruns a singleRestatePromise.raceper loop turn over each advancing group’s awakeable, a sharedctx.sleep(500ms)poll tick, and the control promise - Journal is O(transitions), not O(elapsed) — the poll tick is armed only when a re-attach group (no live awakeable) is advancing, so a long continuously-running phase suspends with zero per-time journal growth
adoptTaskre-attach — a HITL wake CASes the taskawaiting_input → pendingkeeping itsagentId; adoption marks itrunning(notask.startedre-emit) so the run column doesn’t lie and downstreamfrom-guarded CASes still match
- Run-owned awakeable — a dispatched task fires a fire-and-forget VO turn carrying
- Post-completion pipeline & task auto-recovery — settling a completed agent runs
save-task-result→ artifact validation (DEFAULT_CHECKS) →complete, or onedispatchRecoveryre-prompt (emittingworkflow:task.retried), orfail - Failure resilience — categorized errors with bounded retries at every layer
RetryableError→awaiting_retry— a retryable agent failure exits the phase carryingretryAfterMs+ category; a fresh attempt resets non-completed tasks and re-drives- Bounded setup — pre-
mark-runningsetup steps (prepare-read/read-slug/mkdir-artifacts/setup-sandbox) are bounded bySETUP_RETRYand settle the runfailedon exhaustion instead of being silently killed - Per-task timeout —
executionConfig.taskTimeoutMsis an optional per-task safety-net timeout declarable in scenario frontmatter and carried on the blueprint - Terminal-FAIL seam — both failure terminals (required-task phase fail, PR-finalize fail) route one
finalizeRunFailureseam that stops live agents, cancels pending HITL and emits the terminal, so they cannot drift on cleanup
- Per-run sandbox — the run creates and owns a
kind:'run'worktree checkout whenever a brain baseline exists; a single idempotentcleanup-run-sandboxchokepoint fires on terminal return or escape-throw and is deliberately skipped on a resumable return so the resume re-clone lands in the same path-stable checkout - Engine-managed Pull Request finalize — with
executionConfig.pullRequest: truethe engine opens a finalize window after the phase loop rather than letting the planner decompose PR creation- Tracked finalizer agent — a deterministic
pr-finalizer-<runId>slot dispatched through the sameenqueue+awakeable bridge as a task agent, so replay reattaches one slot and a cancel reaches it by name - Honest-failure contract —
prFinalizerOutputSchema(Zod-derived, flatoutcomediscriminator) returns{prUrl, prSummary}orfailed+reason; the engine validates the URL and fails the run rather than completing with a nullprUrl - Safety rails in the prompt — refuses to push the default branch, commits uncommitted work as a safety net, then
git push -u+gh pr list/gh pr create - Planner directive —
PR_PLANNER_DIRECTIVEis injected into the Cascade / one-shot prompts so the planner emits no “create PR” tasks that could push a different branch - Not shared by the loop — the loop’s implementer opens no PR of its own; the engine lands the gated files and opens the PR itself (
createPr,@omnislash-ai/git-ops), so no agent ever holds theghtoken there
- Tracked finalizer agent — a deterministic
- Entry points — every trigger converges on the same handler; fresh runs ride a durable BullMQ trigger inbox, control verbs call the client directly
- Telegram schedule card — Now / Tonight / Custom time / Cancel / Delete, with grant pre-check and input pre-collection before enqueue
- HITL gateway dispatch — grant-rehab approval resumes the run,
input_requestresponse re-enterssubmitFreshwith collected input, schedule-side approval re-runscollectInputOrEnqueue - Cron —
schedule:frontmatter on the scenario file, reconciled intocron-{scenarioId}BullMQ repeatables;workflowTriggerWorkerbridges tosubmitFreshwith a defensive gate that reaps schedulers leaked past a disabled scheduler scenario_start(run:execute, since 1.23.0 — replaces the two engine-specific start tools; brain-dev-guide.md § Renamed engine names) — one start for either engine:id+ optionalinput+ optionalidempotencyKey(pipeline only:service, default the scenario id — its primary — or an auxiliary<id>-<suffix>service one of the scenario’s own*.pipeline.tsregisters, any other refusedinvalid;handler, defaultrun; andkey, default the primary’spipeline.targetKey; the input schema and the input request are the primaryrunhandler’s); refused for a scenario that does not load or is switched off (itself or by its feature — the scheduler brake stops clocks only). A scenario that declares input fields (a workflow’s blueprint; a pipeline’sinputSchema, for itsrunhandler), started withoutinput, opens the input request instead of running and answersinputRequested: { requestId }— the run starts when a person answers it; a keyed call never parks on it (a keyed input-less workflow starts with{}), and on a workflow it is idempotent per scenario for the life of the run row — a repeat answers the existing run withexisting: true. Fire-and-forget: it answersrun: { id, kind, existing }and does not wait (a pipeline that waits on the run usesrunWorkflow); a pipeline’sidis the Restate invocation id, its run row appears as the handler starts- Dashboard tRPC control plane —
run.pause/run.cancel/run.resume/run.retry/run.recover(therun.*ops, one guard set with the tools and the Telegram card) andscenario.start scenario_plan(planning:write, since 1.23.0 — one tool for the in-process planner call and the planning job) — one planner door:dryRun: trueplans now, in-process (Cascade, person-facing stages off, the planner’s 5-minute timeout) and answers the blueprint with its diff against the plan file — from the scenario (id, the same planner input the planning job builds) or a goal no scenario holds (brief); a commit (id) queues the reconciler’s ownauto-plan-<id>-<hash>job so a manual re-plan coalesces with the automatic pass, and answers itsplanningId. Planning ignoresenabled; a locked plan is refused on commit; a pipeline scenario has no plan- Scenario ops (since 1.23.0) —
scenario_get(feature:read: the switch in words — own, effective,heldBy—, the engine, the schedule in words with the next fire, the frontmatter as written and the body, the ownedfiles— definition,.blueprint.yml,*.pipeline.ts—, the plan’s state, the inline hooks; a file that does not load answersscenario: null+broken),scenario_read_file(one owned file with its sha),scenario_update(feature:write, the scenario’s typed keys — name, description, enabled, schedule, skills, agent, chatbot, inputSchema, planning, execution, deliverable, pipeline, the body — neverbudget/evaluation/ inlinehooks),scenario_write_file(the definition or an owned file whole: a.blueprint.ymlvalidated as a run reads it, a*.pipeline.tssize-checked, taking effect on the next pipeline build),scenario_widen(feature:widen, a person: the update / write that adds to the scenario’sgrants:),scenario_delete(the definition, its blueprint, its pipeline modules and its loop module<id>.loop.ts). Every write is D30 (dry run → diff, grant delta,consequences→ commit on the dry run’s sha): a change ofschedule/enabledqueues the schedule reconcile (scheduleReconcile), switching a pipeline scenario off or deleting it kills its live runs (liveRunsKilled, counted on the dry run), a delete names the inline hooks and the clock it takes (inlineHooksRemoved,scheduleRemoved) - A pipeline scenario’s handlers (since 1.23.0) —
scenario_getanswerspipelinefor a pipeline scenario: its Restate primitive, the key a keyless start uses (targetKey, the scenario’spipeline.targetKey) and each handler with the key it needs and the input it takes (therunhandler’s falling back to the scenario’sinputSchema) — whatscenario_start’shandler,keyandinputname; null when the module does not load. Every entity get (scenario_get,heartbeat_get,hook_get) also answers theidit was asked for, so a file that does not load still names its entity - Blueprint ops (
planning:*, since 1.23.0) —blueprint_get(a scenario’s plan file — YAML,sha, lock,freshness, the verdict a run acts on, the task specs — or, withrun, the snapshot that run froze),blueprint_list(every workflow scenario’s plan with its state, phases and tasks; complete),blueprint_write(a structured plan, refused while the plan is locked, or raw YAML, which locks it —consequences.locksBlueprint; validated as a run reads a plan,invalidwith each issue at its field),blueprint_set_lock,blueprint_delete(the plan file only) - Blueprint freshness — the planner stamps
scenarioHashinto every plan it writes, andresolveBlueprintFreshnessturns that intocurrent/stale/unknown(no stamp, or no scenario file). Surfaced byblueprint_get, which is what makes the plan→poll→execute loop terminate: after an edit the OLD plan is still on disk and answers instantly, sostaleis the only signal the re-plan has not landed
- Workflow input parameters — a blueprint may declare
inputSchema(JSON Schema); provided input is validated and injected into every task prompt- Pre-run input HITL — collection lives inside
submitFresh, the point every fresh entry converges on: an input-less submit on a field-bearing blueprint opens aproducer='workflow' kind='input_request'interaction and returns{ kind: 'input_requested' }without creating a run row - Typed forms, not free text — the collected schema is rendered as a typed form in Telegram / dashboard; free-form replies are parsed by
InputSchemaTranslator - Delay survives collection — a delayed submit stamps
targetTimestampand the response side re-derives its submitdelay
- Pre-run input HITL — collection lives inside
- Blueprint-level authorization — the handler’s grants gate (
checkGrants→provisionGrants) blocks the run toawaiting_approvalbefore any sandbox or agent work, using the samegrants.authorizeprimitive as the runtime tool gate (grants/HITL mechanics owned by the policy layer) - Workflow Q&A during execution — an agent question mid-run parks the task
awaiting_inputand the run returns; answering through the HITL gateway wakes the same agent in place and a fresh attempt re-attaches to it — resume never re-prompts a live task - Self-healing runs (remediation dialogue) — a failed run (or a manual “Recover”) spawns a
system:remediationagent that diagnoses the failure, patches the frozen blueprint and re-drives the run — failures become edits, not post-mortems- Outside the FSM — the run stays
failed/paused/awaiting_inputfor the whole dialogue; only the agent’srun_retrycall moves it - Agent toolkit —
run_diagnosis(full root-cause report),AskUserQuestion,run_update_blueprint,run_retry run_update_blueprintsemantics — shallow top-level replace overgoal/assumptions/successCriteria/phases/deliverable/executionConfig, with a hard invariant that every existingtaskSlugmust still resolve (task ids are immutable once a row exists; the tool rejects listing the orphans)run_retrycovers the whole recoverable triple —enqueueWorkflowRedrivedispatches on status (failed→ retry,paused/awaiting_input→ resume) so a Recover on a paused run is not a dead end- Attempt cap + slot idempotency —
config.workflow.maxRecoveryCount(default 3) enforced inside the same CAS that bumpsrecoveryCount;recovery-<runId>is a unique name slot so duplicate triggers collapse to one spawn, and every leg that bails before dispatching refunds the claim - Lifecycle events —
workflow:recovery.started/.completed/.failed, the last carrying a closed-worldreasonand the agent’s own final report (the only delivery path its diagnosis has);RecoveryNotifierposts only the failure - Triggers — automatic on
workflow:run.failed; manually throughrun_recover(the dashboard’s Recover binds the same op) and the Telegram ”🛠 Recover” button — aworkflow-recoveryjob deduplicated per (run, recovery count) while it waits or runs, so a double click or a redelivered request is one dialogue (a redelivered request whose crashed attempt took the slot but never created the agent resumes that claim rather than dropping it), and a Recover after a dialogue that ended without re-driving the run starts a new one;runningruns must be paused first
- Outside the FSM — the run stays
- Orphan reconciliation (lost-run sweep) — a separate deterministic sweep that re-drives run rows nothing is going to advance, routed through the handler and never writing the row
- Two candidate classes —
running/pendingwhose Restate invocation is GONE (30-min indexing grace) and PARKED rows with no waker left (awaiting_input/awaiting_approvalwith no pending HITL row, plusawaiting_retryonce itsretry_athas passed — a run a money budget parked waits for the next local midnight — 2-min grace).pausedis excluded: that suspension is the owner’s to end - Status-filtered liveness —
sys_invocationretains a terminal attempt ascompletedfor ~24h, so the sweep filters it out; otherwise a finished attempt would mask a genuinely lost run for the whole window - False-resume-proof — every candidate the service-wide batch misses is re-verified individually before any verdict, so a stale batch read skips rather than mass-resumes;
skippedStillLive > 0is always logged atinfo. The batch + recheck is the same classifier (classifyWorkflowRunLiveness) every reader of a run’s liveness uses, so the engine has one definition of “this row’s invocation is gone” - Three drivers — a ~5-min scheduler pass under
withRedisLock, a best-effort sweep on pipelines startup, andomnislash workflow reconcile(dry-run by default,--apply,--json)
- Two candidate classes —
- Lifecycle events — the handler is the sole producer and each emit is journaled so it fires exactly once across replays:
workflow:run.started/.resumed/.paused/.cancelled/.completed/.failed,workflow:phase.started/.completed,workflow:task.started/.completed/.failed/.retried, plus agent-sideworkflow:task.progress. Everyworkflow:run.*event carries the run’sscenarioId, so a hook can filter one scenario’s runs (filter: { scenarioId: '<id>' })- Ordering contract — an event fires after the
ctx.runthat produced the state it describes, so “if I seerun.paused, the row sayspaused” is safe to rely on - Journaled terminal finalization —
emitTerminalrunsfinalize-summary→finalize-deliverable→ terminal emit →enqueue-evalas orderedctx.runsteps owned by the run’s sole writer, replacing the old lossy pub/sub worker subscriber that dropped finalization in restart windows and ran it once per replica
- Ordering contract — an event fires after the
- Live run status card — a burst of task/run beats coalesces into one light
findCardRunView(runId)read and renders the run handler’s projectedrow.statusdirectly, so a reordered non-terminal beat can never resurrect a settled card (restart-safe, no in-memory seen-set) - Workflow-scoped hooks —
hooks:declared in scenario YAML fire only for that run’s events, auto-filtered byrunId- Registry lifecycle — registered at attempt start outside
ctx.runso a replay re-attaches, keyed-replace so replays never stack duplicates, unregistered only on terminal exits (resumable returns keep the subscription across a human wait), with a grace delay so the just-emitted terminal still lands - Available events — every
workflow:*event carryingrunId; frontmatter fields mirror global hooks plus a requiredid:and an inlinepromptTemplate(agentdefaults togeneral)
- Registry lifecycle — registered at attempt start outside
- File-first blueprints —
.blueprint.ymlwritten next to the scenario is the single source of truth; no DB blueprint table- Frozen snapshot per run —
blueprintSnapshotis captured at run creation and never re-read; task specs are derived in-memory from it bytaskSlugviabuildTaskSpecs/deriveTaskSpecFromSnapshot - Auto-plan reconciliation —
BlueprintReconcilerwatchesMANIFEST.yaml, scenario files and sibling.blueprint.ymls, hashing content (computeScenarioContentHash/computeBlueprintYamlHash) and enqueueing planning jobs on change, with recently-modified and stale-active-job guards - Run slug minted from the run id —
deriveRunSluggives<blueprint-slug>#<runId[0:8]>, unique by construction (nocount(*), no TOCTOU) and stable forever, soartifacts/runs/<slug>and thewf/<slug>git branch stay valid - LLM-generated run title — a nullable human-readable
titlecolumn alongside the slug
- Frozen snapshot per run —
- Cost & usage analytics — per-task
usageJSONB carries per-model token/cost segments; run-levelcostUsdis aggregated by summing segment costs across the run’s task agents, and rolls up per model / scenario / agent - Tool metrics & run report —
buildToolArtifactsbackward-scans the agent output Redis stream, pairsitem.started/item.completedbyitemIdand flags side-effect tools;RunReportBuilder.buildRunReportderives per-task and total tool counts into aRunToolReport - Result summary —
buildResultSummaryproduces a ~1000-char emoji-prefixed per-task summary (side-effect tool results > task result snippet > error) written at terminal by the run’s ownfinalize-summarystep - Run control tools —
run_cancel,run_pause,run_resume,run_retry,run_recover(run:execute) andrun_kill(run:kill, pipeline force-stop) act on any workflow (run_…) or pipeline (pinv_…) run by its id, with one guard set: the same request is refused with the same code and sentence from an agent’s tool, the dashboard and the Telegram run card —not_found,precondition(the run’s state forbids it, e.g. cancelling a finished run) orinvalid(a verb the kind does not have:killon a workflow,retry/recoveron a pipeline).run_recoverqueues one keyed request per (run, recovery count) and returns at once; the dashboard’s run controls bind these ops - Run read tools —
run_list,run_getandrun_summary(run:read) read every kind of run in one vocabulary — workflow and pipeline runs, heartbeat ticks, hook fires, evals and cycle steps — from theexecutionsrecord: status (queued…cancelled, withneeds_you/pausedreasons), trigger, entity, parent and the run it re-runs.run_listfilters by entity, feature (the entities it ships today), kind, status and a window (1h,24h,today,yesterday,7d,30d,90d,mtd,all, or wall-clockfrom/towith an optionaltz— default7d; the answer says the window it read) and finds a run byq(an id prefix, or text in its entity id, its entity’s name, a workflow run’s slug or title or a pipeline run’s slug); newest first, orsortoldest first, longest first or most expensive first; cursor-paged; an entity’s list holds its own runs and the evals and cycle steps about it,entity: "none"the runs of no entity. Since 1.24.0 a row says what went wrong aserror: { class, head }(waserrorClass): the failure class and its error head — the message reduced to its cause (ids, numbers, times, paths and quoted values collapsed), so two failures of one cause read alike (class:headis the cause’s fingerprint; written by every failure, filled for older failures on the worker’s first boot after the upgrade);startedBylists the runs an agent started,fingerprintthe failures of one cause, andgroupanswersgroupsinstead of rows — perentity(each with itsrun_summaryfigures — since 1.24.0 those groups carrylive,spendUsd,evalPassRateandp50Duration— an entity’s median settled duration, null for a feature group — too), per re-run chain (rerun) or per cause (error, failures and give-ups) — each with its count and newest runs, newest group first.run_gettakes a run id or a workflow / pipeline slug and adds the detail: a workflow run’s input, output (result summary, deliverable file), tasks (what the blueprint asks, definition of done, phase, result cut short) and progress, a pipeline run’s steps (each with its error class, journal entry and where its output is kept), Restate kind and key, stored input and result (each shown up to 4000 chars of JSON) and live Restate state, a heartbeat tick’s or hook fire’s agent turn (turn: its agent, role, model, result, error with its stack and category, failed tool calls, provider, exit code, job, tokens, cost and turns — absent for a skipped or gate-failed fire), every task and step’s start, end and agent;step(a task or step id) reads that one task’s result uncut and that step’s output value;reruns— the runs that re-ran it — andbrainSha— the brain commit the run started on (a replay: its pinned commit), null when unknown; since 1.25.0feature— the feature shipping its entity today (enginefor asystem:role), null for a run of no entity or of one no feature ships any more.run_summarytakes the same scope and window (since 1.25.0 alsostatus: only runs in those statuses — the runs still going withwindow: "all"count what runs now without reading the whole record) and answers its figures — runs, success rate, median duration, spend, each beside the window before — with the outcomes per hour or day, the duration distribution and the counts per status and kind; it says how much of the window is on record (honesty) and answers null, never 0, for what the record does not cover (spend over runs none of which is priced is null: unknown, not free). Runs started before theexecutionsrecord existed (an engine before 1.20) are absent fromrun_list;run_getstill reads a workflow or pipeline one from its detail row - Run timeline (since 1.24.0) —
run_timeline(run:read) lays runs out one lane per entity (a heartbeat that ticks every minute is one lane): each lane’s runs in the window (each run by its start and status, or — past 300 runs — counted per bucket ofbucketMs, aligned on the window’s zone), its clock (cadence), its Late / Down now (marks, judged ashealth_entityjudges them fromexpected_at) and its next fires up toahead(24h/48h/7d; at most 300 —forecastFiresis the total); beside the lanes what listens instead of firing (listening: every enabled hook on its event, scenario-inline hooks included, every live resource watch on its resource), the scheduled prompts that fire withinaheadand the engine passes on a clock.featurenarrows it to one feature,entityto one lane;honestysays how much of the window is on record - Run trace, compare and history (since 1.24.0) —
run_trace,run_compareandrun_history(run:read) read inside one run of any kind.run_traceanswers a tree of timed spans — a workflow’s phases and tasks, a pipeline’s steps, the runs it started, every request it waited on and every turn its agents billed (cost and tokens) — with one span per attempt of a retried task or step (attempt,attemptOf) and the back-off between them (a workflow task’s earlier attempts are kept by the reset that retries it,workflow_task_attempts), andhonesty: the agents reaped since (their spans keep their timing, their cost the spend ledger’s), whether the ledger still holds all the run billed, whether turns were left out.run_comparealigns a run with another — by default the previous finished run of its entity and kind (againstnames any other) — by label path, with the deltas (duration, cost, tokens, status), the input diff leaf by leaf and the two brain commits.run_historyis the run’s log: its agents’ transcript rows (only while each worked for the run — a step agent reused across runs or a tick’s long-lived agent shows this run’s work alone), the pipeline’s lifecycle events and Restate’s journal transitions in one order, cursor-paged, narrowed by log, agent or text; a request’s rows (its payload and answer) only for a caller holdinghitl:read. A sub-agent the run’s agents spawned counts as one of its agents: its turns sit under its spawner’s span. The dashboard’s run log reads it (agent-observability.md § Reading a run) - Re-run, purge and a run’s files (since 1.24.0) —
run_rerun(run:execute) starts a finished workflow or pipeline run’s entity again — with the input the run started with or an editedinput— as a new run linked byrerun_of(run_getlists it underreruns); the input is checked against the schema the entity takes now, whichrun_getanswers asinputSchema;dryRun: trueanswers the input, its schema and what running it again does (sideEffects: the plan’s tasks, a pull request, the deliverable) without starting anything. A live run is refused (cancel or reset it first), as are an ad-hoc run and a scenario that is switched off; a heartbeat tick or hook fire passed torun_rerunorrun_purgeis refusedinvalid(it starts again withheartbeat_start/hook_start).run_purge(run:kill) drops a finished pipeline run’s journal from Restate to free its space — the run’s record, steps and log stay.run_files(run:read) lists what a workflow run produced — its deliverable (once completed), each task’s stored output artifacts with the task that produced them, its PR — andrun_read_filereads one by itsref(text as utf-8, anything else base64, bounded at 2 MB: past it a text file is cut and a binary one comes without its content,truncated, its blob unread when its size is on record); only the run’s own recorded files are readable, never a path - Pipeline reset —
run_reset(run:kill): force-kill a pipeline run if it is still live, then start the same handler again with the full stored input; the new run’srerun_ofnames the old one. The dashboard binds it as Reset on a live pipeline run, also over a selection of runs (one confirm); a finished run takes Run again (run_rerun) instead. A Restate workflow-keyed run is refused — a workflow id runs once — with therun_kill+scenario_startunder a new key route in the refusal. The same reset is what the engine applies to a run a code change breaks underonCodeChange: restart(§ Durable Pipelines › Editing a live pipeline) - Workflow inspection & execution tools (MCP) —
scenario_start,run_list/run_get(every kind of run, below),run_diagnosis,run_update_blueprint,scenario_plan,blueprint_list,blueprint_get— each capability-scoped and routed through the tools gateway
Durable Pipelines (Restate)
- Code-defined workflows (Tier 3) — a brain developer writes flow control as a typed TypeScript Restate handler and inherits the whole engine runtime (agents, tools, skills, HITL, grants, memory, events, notifications, monitoring) as callable infrastructure; every step, branch and error handler is code rather than an agent’s decision
- Escalation path — a scenario starts Tier 1 (
{id}.scenario.mdonly), gains a locked blueprint (Tier 2), then a{id}.pipeline.ts(Tier 3) with no configuration change;resolveScenarioEngine()picks the engine by file presence, overridable withexecution.enginein frontmatter - All three Restate primitives —
restate.service(stateless),restate.workflow(bounded signalable run withctx.promise+ shared handlers) andrestate.object(long-lived single-writer entity) are accepted as the module’sdefaultexport; the kind is detected structurally (detectPrimitiveKind) and stamped on every invocation row, and discovery/instrumentation/registration are uniform across them- Primitive decision guide — a documented decision tree (addressability, then lifetime), worked examples, and reference implementations in
brain-example/features/{service,workflow,object,composite}-example/ - Cross-cutting recipes — Virtual-Object token-bucket rate limiting and self-tail-calling cron, documented as compositions rather than new primitives
- Primitive decision guide — a documented decision tree (addressability, then lifetime), worked examples, and reference implementations in
- Auto-discovery & registration — one loader (
loadServedPipelineModules) takes the enabled pipeline scenarios, resolves{id}.pipeline.tsplus auxiliaries, dynamic-imports them and checks every discovery rule, collecting every failure rather than stopping at the first; a scenario with a broken module is left out and the rest is served (partial boot — chat and workflows never go down with a bad module);registerWithRestate()POSTs the deployment to the admin API with retry andforce: trueon reload, non-blocking so the service starts firstomnislash doctor --check pipelines(CLIPipeline modules) — the same loader without a server: every enabled module imported and validated on the author’s machine or in CI, failing on any module that would not be served and warning on a module that reads env at import time; an engine-owned step of the generated CI and deploy gatesPipeline build(runtime doctor check) — the live endpoint’s own verdict on its newest build, read from the record pipelines publishes: red while a module is not served (naming each module and its error, and what still serves), indoctor_checkand on the dashboard;pipeline_listpoints an agent at itpipeline_build_state(pipeline:read, thepipeline.build_stateop) — the same record as data:serving/degraded/down, what serves and which modules did not load since when;unknownwhen no record could be read — never healthy
- Multi-pipeline per scenario — one user-facing scenario can own a primary pipeline (
name== scenarioId) plus auxiliaries named${scenarioId}-*, so a feature that genuinely decomposes into a fan-out service + per-id workflows + per-entity objects stays one scenario - Hot reload in every mode (including production) — a
*.pipeline.tsedit detected byContentWatcherresets the scenario cache, closes the HTTP/2 server, re-discovers, rebuilds and re-registers withforce: true; constitution JSON edits reload their own caches without triggering a rebuild. What is hot and what waits for a restart:contract.md§ 2.1- Failed reload surfaced — a reload never takes a serving scenario offline: one breaking a scenario that serves now keeps the previous build serving, one breaking only scenarios not served anyway goes live without them; either way it records the failure (
Pipeline build, thepipelines_build_failedgauge →Pipeline Build Failedalert); the next successful build clears it, and while it is recorded any scenario orFEATURE.mdedit retries the build
- Failed reload surfaced — a reload never takes a serving scenario offline: one breaking a scenario that serves now keeps the previous build serving, one breaking only scenarios not served anyway goes live without them; either way it records the failure (
- Pipeline SDK (
@omnislash-ai/pipeline-sdk) — the brain-facing helper surface, each helper journaled throughctx.run, all working identically in all three primitivesrunAgent/safeRunAgent— durable agent turns via a parent awakeable settled by the agent VO;runAgentthrowsTerminalErroron agent failure,safeRunAgentreturns the failure as a typed value (thesafeParsemirror) for fallback / retry / branching- Typed structured output —
outputSchemais a Zod schema: the model gets its JSON Schema, each constraint its provider refuses (.max(4),.regex(),.email()) said in words in the field’s description, and the answer comes back parsed and typed asstructuredOutput; a missing or non-matching output gets correction turns — the same agent is sent the issues and its rejected output (outputCorrections: 1 → MANIFESTpipelines.outputCorrections→ the call,0= none) — then throws terminal inrunAgentand returnsoutputErrorinsafeRunAgent; runtime-only shapes go throughz.fromJSONSchema runAgentWithFallback— cross-mode agent chains that advance on any non-completedstatus or on output failing the sharedoutputSchemaonce an attempt’s correction turns are spentrunWorkflow/safeRunWorkflow— a Tier-1/2 scenario as one durable pipeline step: launches the blueprint run (created by the pipeline invocation, idempotent on an optional key shared withscenario_start) and waits for its terminal across every attempt — parks on a person or a retry included;runWorkflowthrows on afailed/cancelledrun,safeRunWorkflowreturns theWorkflowRunOutcome; cancelling the pipeline cancels the run; refused underctx.loopLaunchedawaitApproval/requestApproval/awaitApprovalDispatch— blocking approval (suspends on an awakeable, returns the tappedactionId, supports custom action sets; the card expires after 168 h unlesspipelines.approvalExpiresInHoursor the call’sexpiresInMssays otherwise) and detached approval (returnshitlIdimmediately for hook-driven follow-up)askQuestion/requestGrant/requestInput— the other three HITL kinds, each awakeable-backed and typedcallTool— full ToolsGateway execution (authorization, rate limiting, risk-based HITL) with scenario and surface resolved from the invocation context; since 1.24.0{ role }makes the call speak as one of the brain’s roles —voice_send/voice_renderin itsspeech:voice, a surfaceless delivery through its persona bot — while the step keeps its scenario’s grants (an unknown role fails the call)notify— Telegram delivery with a precedence chainopts.surface(a delivery target the caller already holds whole, persona included — the escape hatch for RAW-served handlers that have no invocation context) →opts.chatId(persona borrowed from the status chat) → the run’s own chat picked byopts.to(ops, the default: the pipeline’s ops surface, else the invocation surface;invocation: the chat the run was started from, where its HITL cards go) → engine status chatemitEvent— typed emission of anypipeline:*event onto the engine bus; emits nothing underctx.loopLaunchedreportProgress— validated{percentage?, stage?, message?}written topipeline_invocations.progress(replace, not append) plus a liveness heartbeat, emitted aspipeline:run.progress, and deliberately excluded from step rowsreportRunSummary— a synchronous ALS-only human-facing one-liner shipped onpipeline:run.completed; display metadata that is never journaled and cannot break determinismsubscribeResource/waitForResource— durably wait for events about a resource key (a PR, an issue, your own source’s key):subscribeResource(ctx, resource, { name, expiresAtMs, sinceMs?, policy?, createdBy? })returns a subscription whosenext({ timeoutMs })answers{ kind: 'events', events }or{ kind: 'timeout' }— events recorded while the pipeline was busy elsewhere are handed over by the nextnext, nothing falls between waits;close()it in afinally.waitForResourceis one open-wait-close. A timeout is mandatory, and an event is a signal to re-read the resource, never its state. Pipelines hear everyone, their own App included ({ authors: 'all', dropEcho: false }unless thepolicysays otherwise); the loop’s merge wait runs on itkvGet/kvSet/kvDelete— PostgreSQL-backed persistent key-value storage namespaced byscope, with an optionalexpiresAtTTL, for cross-invocation state;kvSet/kvDeletewrite nothing underctx.loopLaunchedrecordSpend— books money the pipeline paid outside any agent turn (a metered API it called) on the spend ledger as akind: 'external'row attributed to the invocation; recorded once across replays and retried writesruntime()escape hatch — direct access to the injected DI bag (agents,toolsGateway,hitlGateway,notificationQueue,evalQueue,redis,kv,statusSurface,blobStorage,updateRunProgress,restateAdminUrl) for capabilities not wrapped in a helper- Restate re-export — the SDK re-exports
@restatedev/restate-sdkasrestateso brain and engine share one module instance and therefore oneHANDLER_SYMBOLidentity
- Per-handler input schemas (
withInputSchema) —withInputSchema.{service,exclusive,shared,workflow,workflowShared}(schema, fn, opts?)attaches a Zod schema that becomes both the Restate ingress serde (validated at the edge, published in the admin JSON Schema) and the engine’sPipelineHandlerMeta.inputSchemaused to render trigger drawers; an optionaldescribestring becomes the owner-facinghandlerLabelon the run card - Handler serde + kind preservation — the engine re-wraps every handler for instrumentation, and
rewrapPreservingOptionsre-applies the originalHandlerWrapper.{kind, options}through the matching SDK factory, so input serde survives and a VOsharedhandler is not silently downgraded toexclusive - Declarative pre-run input collection — a workflow-primitive pipeline scenario declaring
inputSchemain frontmatter gets a HITL form before the pipeline starts on the Telegram/rundeep link, and an inline schema-rendered drawer in the dashboard; the handler signature is unchanged and no SDK helper is involved pipelineConfig.opsSurface— a named export besidedefaultlets a pipeline declare the chat for everything it emits by itself (its live run card and everynotifywithout asurface/chatId/to: 'invocation';botIdoptional, the chief’s bot by default), winning over trigger meta and scenario surface and inherited by its child invocations (nulldeclares the engine status chat); HITL cards, agents and tool calls stay on the invocation surface, so approval cards and ops notices can live in two chats. An unknownpipelineConfigkey fails discovery- Automatic causal-tree propagation — the engine wraps the handler
ctxand injects trigger meta (rootRunId/parentRunId) into all six client methods, so child invocations spawned with plainctx.workflowClient/objectClient/serviceClient(and their send variants) render inside the parent’s card with no manual wrapping - Live agent progress in run cards —
runAgentbridges the agent’s Redis output stream from the pipelines process (outsidectx.run, idempotent peragentId, self-stopping onturn.completed) and republishes non-terminal events aspipeline:step.agent_eventcarrying(pipelineId, runId, stepId, agentId), so tool calls and retries render as nested children under the step instead of one static line - Durable observability tables —
pipeline_invocations(one row per invocation across all three primitives, withslug,kind,objectKey, status, input/result/error,totalStepCount,progress,lastProgressAt(progress liveness), pause/resume/cancel timestamps),pipeline_steps(one row per user-visiblectx.run, with monotonicstepIndex, error class, duration, journal index and anagentIdFK),pipeline_events(timeline audit trail written synchronously alongside the live emit) andpipeline_journal_events(1:1 mirror of Restate’s per-attempt failure timeline)- Best-effort vs durable split — step/event rows are best-effort (a DB failure is logged, never thrown), while run create/complete/fail are journaled
ctx.runsteps; Restate’s own journal stays the source of truth - Replay-safe step rows — persistence and step events execute inside the Restate
ctx.runthey describe, so a replay returns the cached result and produces no duplicates; an explicitretryPolicydeliberately yields one row per attempt - Large-payload blob offload — a step output over
config.pipelines.blobThresholdBytes(64 KB) is written topipeline-runs/<slug>/steps/and persisted throughBlobStorageRouter, stored as aStoredPayloadblob ref; non-serializable values degrade to a sentinel rather than aborting observability - Derived current step —
findCurrentPipelineStep(runId)computes the newestrunningstep on the fly instead of storing acurrentStepId, becauseRestatePromise.alllegitimately runs several steps at once - Restate SQL mirror — on completion the wrapper journals one enrichment pass over
sys_invocation/sys_journal/sys_journal_events, patching retry count, completion result, journal sizes, pinned/last-attempt deployment, parent invocation and flattened last-failure attributes, correlatingRunjournal indices onto step rows, and bulk-inserting journal events; failures are swallowed as enrichment, bounded by a 5s admin timeout and guarded against SQL injection - Query API —
findPipelineInvocationById,findPipelineInvocationWithSteps,findPipelineStepsByInvocationId,findCurrentPipelineStep, plus typed Restate introspection wrappers (queryRestate,queryRestateInvocation,queryRestateJournal,queryRestateJournalEvents,mirrorRestateInvocation)
- Best-effort vs durable split — step/event rows are best-effort (a DB failure is logged, never thrown), while run create/complete/fail are journaled
- Pipeline lifecycle events —
pipeline:run.{started,completed,failed,cancelled,paused,resumed,killed,progress}andpipeline:step.{started,completed,failed,retried,agent_event}, auto-emitted by discovery-level handler wrapping so a brain pipeline gets them without writing any - Lifecycle control tools (MCP) —
scenario_starton a pipeline scenario (optionalidempotencyKey, passed raw as Restate’s idempotency key: a repeat within the service’sidempotencyRetentionanswers the first invocation withexisting: true; a Workflowrundedups on itskey),pipeline_list, and the run tools shared with workflows (run_list,run_get— a pipeline run’s steps, Restate kind and key, stored input and live Restate state;run_cancel— graceful, compensation runs, cascades to nested invocations;run_pause,run_resume,run_kill—run:kill,dangerous, no compensation;run_reset—run:kill: kill, then a new run on the full stored input, linked byrerun_of) — all keyed on the DB run id, resolving the Restate invocation id internally, withPipelineLifecycleapplying status CASes sopausedAt/resumedAt/cancelledAtstay in sync with Restate - Deploy-spanning retries that end in pause, never kill — the endpoint sets
DEFAULT_INVOCATION_RETRY_POLICY(maxAttempts: 70, 1s→60s backoff, ≈1h,onMaxAttempts: 'pause') for every service, object and workflow bound to it: a deploy’s endpoint gap is outlived, and a failure no retry fixes parks the invocation with its journal intact instead of destroying it. A service’s ownoptions.retryPolicyreplaces the default whole (Restate merges service options shallowly) - Paused invocations surfaced — the orphan reconciler projects Restate’s
pausedonto the pipeline row (running ↔ paused, with the card’spipeline:run.paused/resumed), andrestate_invocations_paused{restate_service}feeds the criticalRestate Invocations Pausedalert, counting only pauses caused by an exhausted budget (a deliberate Pause is not counted). Every such pause of ANY service — a brain pipeline, an agent turn, a workflow attempt, an engine handler — is listed byruntime_overview(paused: service, key, failure, journal mismatch or not;summary.paused) and by the runtime doctor’s Paused invocations (doctor_check, the dashboard’s Doctor page) (runtime preset, beside Pipeline build), whose hint names the tool that ends each kind (run_resume/run_reset/run_killfor a pipeline,agent_stopfor an agent turn,run_retryfor a workflow attempt) - Replay-stable agent dispatch — every agent turn a durable handler sends (
runAgent, workflow tasks, loop turns) journals its built request before sending it, so a prompt, skill or role edit landing while the invocation waits never breaks its replay (RT0016); turns not yet sent pick the new content up - Error model — a plain throw inside
ctx.runis retryable and replays from the failure point; a throw that leaves the handler fails the run on that attempt (the engine settles itfailedand reports it terminal), while arestate.RetryableErrorreplays it;restate.TerminalErrorstops immediately; a documented compensation/saga pattern unwinds executed steps on terminal failure or cancel - Restate SDK pinned at
1.14.5— an exact pin (not neglect): from 1.15.0 aCancelledErrorrejects theawaitoverctx.run, which breaks the agent VO’s post-cancel “death certificate” write and wedges the whole workflow cancel cascade; the migration to explicit-cancellation mode is tracked, and the pin is verified by a cancel integration suite - Test-server version parity — the SDK test harness boots the same pinned Restate server image as the compose templates, enforced by an image-pin test so a drift breaks the build instead of silently testing a different server
- Engine built-in services —
workflow-run,planning-run, theagentVirtual Object, theagent-limiterVirtual Object and thegreeter/agent-demo/approval-demodemos are served from the same endpoint;ENGINE_BUILTIN_SERVICE_NAMESis the single allowlist and drift againstgetEngineServices()is a boot-time fail-fast assert; a brain pipeline named like one of them is not served (a discovery failure) rather than silently replacing it - Orphan reconciliation —
reconcilePipelineOrphanscloses the three holes the terminal write cannot: a deleted.pipeline.tswith in-flight invocations, a handler that died between Restate completion and the engine’s terminal write, and an invocation Restate has forgotten- Removed-service drain — every live invocation (
sys_invocation, not yetcompleted) whose service is absent from the current discovery snapshot and is not an engine built-in is killed, and its row then settles ascancelled; found by invocation because the endpoint’s forced re-register already dropped the service itself from Restate; idempotent, so a converged world produces an empty report - DB row sync — every
running/pausedrow is reconciled againstsys_invocation(success →completed, a Restate cancel/kill ([409]) →cancelled, other failure →failed, in-flight → only itsrunning/pausedsplit mirrored, unknown past a 30-min heartbeat TTL →failed (orphaned)) - Three drivers — pipelines startup, every hot reload, and a 5-minute scheduler pass, plus the manual
omnislash pipeline reconcile(--dry-rundefault,--apply,--json)
- Removed-service drain — every live invocation (
- Editing a live pipeline — Restate’s in-place update: in-flight invocations continue on the NEW code; prompt/skill/role edits are safe (engine helpers journal what they resolved), while a change to the order or inputs of a pipeline’s own
ctxoperations breaks replay- A code change settles the runs it breaks (since 1.27.0) — on every start (a deploy, as it comes up) and every hot reload (before the new build serves), each live run’s journal is replayed against it offline, nothing executed; a run the new code cannot replay is settled at once by
onCodeChange:end(engine default — killed asrun_killkills it, pages, watches and cards included) orrestart(reset asrun_reset— the steps before the wait run again, started as the original was: same person, same chat; a keyedrestate.workflowrun and a run another run started are ended instead) → MANIFESTpipelines.onCodeChange→ the scenario’sexecution.onCodeChange; no card — the reason on the run’serror, the action on the action log, one message per settle to the status chat - Undecided runs keep the wake-time path — a run the check could not decide pauses visibly at wake (RT0016, reason on the run) instead of vanishing, and the agent repairs it: a journal-compatible fix +
run_resume, elserun_reset(aworkflow-kind run:run_kill+scenario_startunder a new key)
- A code change settles the runs it breaks (since 1.27.0) — on every start (a deploy, as it comes up) and every hot reload (before the new build serves), each live run’s journal is replayed against it offline, nothing executed; a run the new code cannot replay is settled at once by
- Developer CLI —
omnislash pipeline run <name> [input](with--handler,--target-key,--idempotency-key) andomnislash pipeline status <key> --name <name>drive the Restate ingress directly - Container & runtime — a dedicated
pipelinesservice (HTTP/2 onPIPELINES_PORT, health/metrics onHEALTH_PORT) with its own DI container (agents facade, tools, grants + HitlGateway + ToolsGateway, memory, skills, queues, KV, blob storage, status surface), health-checking the Restate admin/health, and a documented startup/shutdown sequence
Artifacts & Deliverables
- Run artifacts — file-based context passing — planner-declared
outputArtifactsare ordinary files an agent writes into$ARTIFACTS_ROOT/runs/<run-slug>/; later phases receive a lightweight manifest and pull what they need with native Read/Grep/Glob, so no new agent tools, no runtime storage service and nodependsOngraph are needed (phases already give ordering)- Pull, not push — only a manifest of filenames and sizes goes into the prompt, which scales to hundreds of tasks; the agent decides what and how much to read
- Manifest assembly —
buildArtifactsManifest(dir)scans the directory andformatManifest(entries, dir)renders the markdown section, injected into the task prompt only once files exist (phase 2+) - Engine-runtime, not brain source — artifacts live under
$ARTIFACTS_ROOT(prod/var/lib/omnislash/artifacts), a sibling of the runs root and brain baseline, and are bind-mounted RW into the per-run sandbox at the same absolute path so agent writes and engine reads hit one inode
- Declared artifact subtree layout —
ARTIFACT_SUBTREESnames every subtree and its lifecycle (storevsscratch), and every writer resolves its directory throughgetArtifactSubtree(key):blobs/(store),blobs/.tmp/,runs/<slug>/,agents/<id>/,telegram/<bucket>/,conversation-uploads/,pipeline-runs/<slug>/steps/- Derived coverage — retention, the
/artifactsbrowser’s hidden roots andgetReapableArtifactDirs()are all derived from that one table, so a new subtree cannot silently escape the reaper; a test fails any call site that joins onto the root instead
- Derived coverage — retention, the
- Artifacts retention reaper — the scheduler sweeps every
scratchsubtree under a brain-configuredartifactsRetentionpolicy inomnislash.config.ts- Two independent optional dimensions —
ttlHours(age cut, runs first) andmaxSizeMb(a single oldest-first budget over the union of reapable subtrees); with neither set nothing is deleted — there is no implicit default cut - Cadence & robustness — fires once at startup then every 6h, walks each root with a streaming
opendirso memory stays bounded, skips a single unreadable root instead of aborting the sweep, prunes emptied directories, and skips eviction entirely when undeletable TTL-expired bytes already meet the cap - Deliberate exclusions —
blobs/(referenced by DB rows) andruns/(interleaves with in-flight runs, needs a lifecycle-aware sweep) are excluded;omnislash doctorwarns when the whole tree exceeds 5 GB
- Two independent optional dimensions —
- Content-addressed blob store —
$ARTIFACTS_ROOT/blobs/behind every persisted blob ref, with the durability properties a long-lived reference needs- Persist copies, never links —
persist(path)copies the source intoblobs/.tmp/, streams a sha256 of that copy, then renames it toblobs/<sha[0:2]>/<sha>-<name>; the rename is what stops a reader seeing a partial blob, and copying (not hardlinking) is what keeps the blob immutable while agents rewrite artifact files in place - Honest naming & idempotency — hashing the copy rather than the source means the digest always describes the bytes stored, and identical content lands on the same destination so a repeat
persist()drops the staged copy — dedup and retry-idempotency for free sizeBytesfrom the stored copy — measured on what the ref will actually serve, not a separatestatof a source someone else may have rewrittenmaxBytesenforced during staging — an oversize file throwsBlobTooLargeErrorbefore anything is promoted, which is the only point where a refusal costs nothing (the store is never age-cut and a written digest may already back a live publication)- Symlink containment — the source is opened
O_NOFOLLOWso a symlink at the final component fails instead of being followed into the store; every caller handspersist()arealpathWithinresult, and the inline (non-blob) text paths canonicalize the same way retrieve()refuses any path outside the store root — canonicalized throughrealpathWithin, so a blob ref can never be made to name an arbitrary file on disk; a missing blob still surfaces asENOENT- Refs are absolute paths, not bare hashes — deliberately, so the API stream reader and CLI agents can
Readthem natively and the original filename stays visible; content addressing lives in the directory layout
- Persist copies, never links —
- Blob store GC — refcounted mark-and-sweep — the scheduler’s
BlobStoreGc(6h cadence + startup fire) snapshots the live reference set from every durable DB site (collectLiveBlobRefsoverLIVE_REF_SITES: published artifacts, run deliverables, task artifacts, pipeline step outputs, conversation-event attachments, agent inbox attachments) andLocalFileBlobStorage.sweep()quarantines unreferenced blobs older than the grace window (blobGc.graceHours, engine default 72h, brain-tunable) intoblobs/.trash/, purging quarantine a full second window later- Liveness by basename — refs match on the content-derived
<sha>-<name>, so a movedARTIFACTS_ROOTcannot orphan the store - Dedup-hit touch —
persist()refreshes mtime when it dedups onto an existing blob, closing the re-publish-vs-sweep race; the grace window covers every transient ref carrier (queued deliveries, journal entries, a browser holding an upload ref) - Quarantine as backstop — a referencing site the collector missed becomes a recoverable 404 with an undo window, not a silent loss; a quarantined blob a live ref names is restored to its shard on the next sweep (self-healing for every known site), and
blob-ref-coverage.test.tssource-scans the schema so a new ref-bearing column fails CI until classified
- Liveness by basename — refs match on the content-derived
BlobStorageRouterwith pluggable providers — refs carry a URI scheme (local://, futures3://) that routes retrieval, so old refs keep resolving when the default provider changes; a persist failure on an external default falls back to the local provider, andcreateBlobStorage()is the single construction site where an R2/S3 provider registers- Per-task stored artifacts — as each task completes,
storeTaskArtifacts()reads the files the task actually produced and persists them ontoworkflow_task_runs.storedArtifacts(text inline as{storage:'inline', content}, binary as{storage:'blob', url}), so long-lived consumers (eval, dashboard) keep working after the run directory is gone- Bound to task completion, not run completion — the column reflects real files even when the run later fails, and the dashboard shows produced files rather than planned
outputArtifacts
- Bound to task completion, not run completion — the column reflects real files even when the run later fails, and the dashboard shows produced files rather than planned
- Task outcome envelope — EVERY task is dispatched with one structured-output schema (
buildTaskOutputSchema):{success, error, summary, artifacts}.artifacts[].filenameis anenumpinned to that task’s real artifact names (null-typed when the task declares none), so the agent must measure each file with a real tool call instead of claiming it wrote one; the deliverable itself is NOT in the envelope — it is the file, anddeliverable.schemais applied to that file afterwards (checkDeliverableContent);success:false+erroris the honest-failure arm, so an agent never has to fabricate a result to satisfy the schema- Hard failure is terminal, not retried — a
success:falseverdict fails the task immediately with the agent’s ownerroras the reason, in its own branch past the infrastructure-failure branch soclassifyErrornever reads it; a blocker that happens to say “rate limit on the vendor API” can no longer push a business verdict intoawaiting_retryand re-drive a task that already established it cannot be done - A turn that reports nothing is a validation issue, not a verdict — a missing or unparseable envelope emits an
unreported-outcomeissue into the same listcheckMissingArtifactsfills, so it inherits that seam verbatim (one recovery re-prompt, then fail);buildRecoveryPromptbranches on the key, keeping the file-oriented “do NOT start over” wording tomissing-artifacts optional: truestill wins — the brain author’s declaration outranks the agent’s verdict: an optional task’s hard failure fails that task and the run continues; on the deliverable task it fails the run before the PR-finalizer window
- Hard failure is terminal, not retried — a
- Artifact validation & auto-recovery —
checkMissingArtifacts(part ofDEFAULT_CHECKS) probes each declared file on disk after a task’s agent completes and, on a miss, emits amissing-artifactsissue with a ready-made prompt that triggers one recovery re-prompt - Deliverable — the run’s primary output — the planner decides what (description, filename, format, schema), the scenario decides how it is stored, and
assembleBlueprint()injectsstorageso it is never an LLM decision- Storage strategies —
inline(text content in a JSONB column, the default),blob(URI ref +sizeBytesrecorded at persist time so senders can gate on platform limits before materializing) andnone(the action itself is the result;resultSummaryis the only user-facing output) - Format-driven routing — the closed
TEXT_FORMATSset decides text-vs-binary; anything outside it (including unknown formats) goes to blob storage - Scenario configuration —
deliverable: { storage, format }in scenario frontmatter, both optional;formatbecomes a HARD CONSTRAINT injected into the planner’s deliverable stage, whilefilenameis always the planner’s job - Execution —
resolveDeliverable()builds a ”## Final Deliverable” prompt section with the full path (and parses a JSON Schema into anoutputSchemaforformat: json), given only to the last task - Completion — the run handler’s journaled
finalize-deliverablestep runsresolveAndStoreDeliverable(), storing a blob ref or inline content onworkflow_runs.deliverable; a missing file logs a warning and skips rather than silently falling back to the last task result - Consumption — consumers read the DB only: the dashboard retrieves and base64-encodes blobs for download, and the Telegram path gates on size before retrieving (
messenger.maxFileBytes) - Typed union —
storedDeliverableSchemais a Zod discriminated union onstoragewhose shared meta (description,filename,format) is derived fromDeliverableSchema;storedArtifactSchemamirrors it at task granularity
- Storage strategies —
- Run output channels — a run produces six distinct, non-interchangeable outputs:
taskRun.result(the task agent’s own prose summary of its turn),run.resultSummary(short notification summary), the deliverable file on disk, the agent Redis output stream (~7d TTL, ~1000 entries), in-memory tool artifacts extracted from that stream, andtaskRun.storedArtifacts - Artifact publishing —
artifact_publish(path, …)snapshots a file into the blob store and returns a browser-openable link on a dedicated content origin;artifact_unpublish(id)revokes it- Publications as ops (since 1.24.0) — everything after the publish is an
artifact.*engine op, one agent tool and one dashboard procedure each:artifact_list(every publication, expired and revoked included; filtersstatus,visibility,publisheractor kind,agentId,q; keyset; counts and whether private links are servable),artifact_get(the row and its trail — published / replaced / meta edited / revoked, each actor with its session),artifact_read_file(the content, bounded at 1 MB),artifact_update(retitle, lifetime from now —0expires now,nullpermanent, extending an expired one serves it again — make private),artifact_widen(make public or rename —artifact:widen, restricted, a person approves),artifact_unpublish(converted, contract unchanged) andartifact_delete(drops the record, frees its name —artifact:widentoo); every write is an action-log row againstartifact:<id>. The dashboard downloads a publication whole at/artifact-file?id= - Readable names, not secrets — every publication carries an
aliasserved at/a/<alias>(derived by thefastmodel chain fromtitle ?? filename stem, slugified, suffixed until free, or caller-pinned and validated againstARTIFACT_ALIAS_RE); the cuid2/p/<id>URL remains the mutation handle and the unlisted escape hatch - Private by default —
visibility: 'private'serves only past an app-side admin Basic-auth gate (ADMIN_USER/ADMIN_PASS_HASH), returning401 + WWW-Authenticateso the browser prompts once per origin;'public'is the deliberate, guessable-by-name opt-out, and on a credential-less deployment the default falls back to'public'— announced in the tool schema, at registration, and byomnislash doctor - Snapshot semantics — the row references an immutable copy, so the agent may keep rewriting or lose the working file while the link keeps serving the bytes as published
- Living documents —
replacesre-points an existing publication at new bytes keeping the URL (refused on revoked or expired rows, and refused when the file extension differs, since the extension picks the renderer);expiresInHoursstamps a deadline thatreplacesre-stamps - Derived visibility, never swept — expiry and revocation take effect because the serving lookup stops matching, not because a cron flips a status; a revoked or expired link is a 404 indistinguishable from one that never existed, and the row survives as an audit trail
- Names are held until deletion — uniqueness spans the whole table, so neither revoking nor expiring returns a name to circulation; only a delete (
artifact_delete) or a rename (artifact_widen) releases it, which is what makes the URL worth bookmarking - Capability-scoped, not publisher-scoped — any caller holding
artifact:publishand the id may replace, edit or revoke (and holdingartifact:widenrename or delete), which is what makes cross-actor revoke work;published_by/replaced_by/revoked_byand the action log are audit, never authorization - Widening is a person’s — a content replace never moves the name or the audience;
artifact_updateedits title and expiry and makes a publication private, and refuses to make one public or rename it — a freed name can be claimed by a new public publication, serving the old link to anyone — bothartifact_widenunderartifact:widen(restricted,hitl: true), whichartifact_deletesits under too; the private direction is refused when admin credentials are unset - Rendering table —
.md/.markdownrender to HTML throughmarkdown-itwith raw HTML escaped,.html/.htmpass through as authored, everything else is served raw with a detected MIME;/rawalways downloads, and markdown overmaxRenderBytes(1 MB) falls through to raw so a synchronous render cannot stall the webhooks event loop - Sandboxed pages — every response carries
default-src 'none',sandboxwithoutallow-same-origin(opaque origin, no cookies/storage/network),nosniff,no-referrerand anoindex, nofollowheader, so a published page can render but cannot exfiltrate;img-srcnames the artifacts origin literally (never'self', which matches nothing inside an opaque origin) plusdata:, so a page loads the tab mark and images published beside it and nothing else - Exact caching — a strong
ETagof(id, updatedAt)withCache-Control: private, no-cache, so a revoke lands on the next request while a widely-shared link is answered by revalidation before the blob is ever read - Source-path containment —
pathmust resolve (viarealpathWithin, not lexical containment) inside the caller’s own artifact roots —agents/<agentId>/, plusruns/<slug>/for a workflow task — never the whole$ARTIFACTS_ROOT;file_senddraws the identical boundary - Size cap — files over
config.artifacts.maxPublishBytes(25 MB) are refused during blob staging, so nothing is stored - Conditional registration —
artifact_publishregisters only whenARTIFACTS_PUBLIC_URLresolves (explicit →https://artifacts.<PUBLIC_DOMAIN>→ the webhooks public URL), so a brain with no public origin never sees a tool promising a URL nobody can open - Dedicated origin — in domain mode the
artifacts.<PUBLIC_DOMAIN>vhost proxies only/p/*,/a/*and/favicon.ico(the tab mark the browser probes at whatever origin it opened — yourassets/favicon.*, engine placeholder second) and 404s everything else, while the webhooks vhost symmetrically 404s the publication paths — one canonical origin per resource; requests are rate-limited per handle with separate id and alias buckets - Capability —
artifact:publishatrisk: 'moderate',hitl: false(there is no destination argument; the origin is engine-owned config and the act is revocable), overridable tohitl: trueinconstitution/engine-capabilities.json;artifact:widenatrisk: 'restricted',hitl: true, granted to no role by default - Serve observability — every request to
/p/*and/a/*, including refused ones, is counted once aspublished_artifact_requests_total{handle, variant, outcome}and logged once with therefthe reader used (per-link attribution deliberately in logs, not in an unbounded metric label) - Data model —
omnislash.published_artifactswithblob_ref,filename,content_type,size_bytes,title, uniquealias,expires_at,visibility, and thepublished_by/replaced_at/replaced_by/revoked_at/revoked_byaudit columns
- Publications as ops (since 1.24.0) — everything after the publish is an
Evaluation & Self-Evolving
- Run evaluation — a completed execution is graded by an LLM evaluator against the plan’s own criteria, producing a score, a per-criterion verdict and a pass/fail
- Two levels for workflows — per-task Definition-of-Done scoring (only for completed tasks with a non-empty DoD) plus a run-level check of
successCriteria, customrequirementsand the deliverable; the overall score is0.6 × task average + 0.4 × run scoreand overall pass requires all tasks, the run and every custom requirement to pass - Deliverable verification — file existence, format correctness and schema conformance, read directly from disk in the worker rather than from the DB copy
- Grounding & tool evidence — the evaluator prompt is fed the run’s tool artifacts and per-task tool report, so the verdict is checked against what actually happened rather than the agent’s narration
- Structured output, fail-closed — Zod-derived run/task output schemas and a per-call agent timeout well inside the eval lease. A judge that answers with nothing FAILS the evaluation (
judge: no structured output) — there is no rescue model, because a grade produced by a judge nobody declared cannot be compared against a baseline the declared one produced — and output that arrived but does not match the schema fails it too (judge: unparseable output, announced first aseval.parse_failed) rather than being recorded as a completed grade of zero, which would make a malfunctioning judge look like a run that lost - Three execution kinds — workflow runs, pipeline invocations and heartbeat ticks each have their own queue and prompt builder; a
runKinddiscriminator plus a CHECK constraint keeps pipeline evals free of per-task results
- Two levels for workflows — per-task Definition-of-Done scoring (only for completed tasks with a non-empty DoD) plus a run-level check of
- Grading without a judge —
evaluation.method: none— for an entity whose “good” is entirely a number its code already has: no model is called, the grade is the computed catalog plusmeasure()/window(),passedis read off the declared floors,qualityis only reported and no quality guard is added, and nosample.maxPerDayceiling is owed (nothing is spent). A judged metric,qualityas objective/guard, orrequirementsfail the load. The cycle pins a judge ofnoneplus the loop module’s digest, so editingmeasure()mid-cycle is a judge change - Eval methods are brain files —
constitution/Evaluation/Methods/<method>.mdselected byevaluation.method, requiring a# Run Evaluationsection with an optional# Task Evaluationone; add a method witheval_write_method(dry run first — a file without# Run Evaluationis refused)eval_write_method(config:write) — writes one method file, validated as the loader parses it (a# Run Evaluationsection), D30-shaped (dry run →expectedSha→ compare-and-swap);noneis reserved. The dashboard’s Eval Methods page calls the same op
- Per-entity evaluation config — an
evaluation:frontmatter block withenabled,method,modelRef, run-levelrequirementsandsample, resolved across three tiers (engine default →constitution/MANIFEST.yaml→ the entity’s own block) merged one level deep withsamplemerged field-by-field- Manifest kill switch —
evaluation.enabled: falsein the manifest ANDs with each entity’s flag, stopping all grading at once without touching entity files; the manifest never opts an entity in - Hot-synced — the manifest is editable from the dashboard and takes effect without a redeploy
- Manifest kill switch —
- Declared metrics — the brain says what “better” means — every graded execution produces a VECTOR of named numbers (
evaluations.metrics), and every cycle verdict is a comparator over vectors. The engine fixes that shape and nothing else: which metrics exist, how they are measured and which one is the objective belong to the brain author- Three tiers, like every other loop value —
evaluation.metricsis a map of metric id → declaration, merged engine catalog →constitution/MANIFEST.yaml→ entity frontmatter, per id. Identity fields (kind,direction,scope,unit,criterion,human,scale) belong to the tier that INTRODUCES a metric and cannot be redefined lower down; usage fields (role,target,tolerance,tolerancePct,floor,mustImprove,critical,aggregate,optional) belong to the entity and every tier may override them - A metric outside the catalog must declare
roleas well askindanddirection—MetricSpecErrornaming the missing key otherwise, surfaced per entity byomnislash doctor→ Loop modules. Defaulting it toobservewas the quiet failure: an author who declares a metric has by definition decided it matters, and reading their silence as “measure it and ignore it” ships an entity whose verdict never mentions the number it was written for - A metric can belong to a PERIOD instead of a run —
scope: run(the default, every catalog metric, every metric before windows existed) means the number is a property of ONE execution:measure()produces it at grading and the pre-merge check compares it pair by pair.scope: periodmeans it belongs to the entity over a window — followers, a week’s revenue, the share of drafts a human approved — so no execution can produce it andwindow()measures it once per window onto the window record. Identity, not usage: one id cannot be a single run’s property here and the account’s total there.periodimpliescomputedand rules outcritical(both load errors: a judge has no execution to score, and a per-input veto never reaches an input). The consequence worth knowing before you declare one — the pre-merge check cannot read it, so an entity whose ONLY objective is period-scoped merges on a guards-only check, whichomnislash doctor→ Loop check names - Engine catalog —
quality(today’soverallScore),passed(today’soverallPassed),cost_usd,duration_ms,tool_failures. Measured for every graded execution whether or not anyone declared them, so a metric promoted fromobservetoguardalready has history to compare against. Their ids are reserved - Three producers, one contract — the evaluation outcome fills
quality/passed, engine telemetry fills the computed catalog from data it already holds, and the eval agent scores whatever judged metrics the brain declared. Judged ids reach the agent as a runtimeenumin a per-entity output schema, so it can neither invent a metric nor misspell one — and a declared metric it fails to answer fails the evaluation loudly rather than defaulting to zero overallScore/overallPassedare the projection — kept as indexed columns for dashboards, trends and the trigger’s shortcut, written from the vector in one place so the two cannot drift- Anti-Goodhart floor — the moment an entity declares any objective other than
quality,qualitybecomes a guard automatically. Opting out is possible but must be written down (quality: { role: observe }), which puts it in a diff and under the loop’s protection rules - Zero-declaration default is today’s behaviour exactly —
qualityobjective,passedguard, the rest observed quality’s own recipe is brain-tunable —evaluation.scoreWeights(engine 60/40 tasks-vs-run → manifest → entity) decides how a workflow scenario’s per-task grades weigh against its run grade. Relative weights normalized by their total, soqualitycannot leave its 0..1 scale whatever pair is chosen; declared as a pair, since half a split silently re-weighs the other sideaggregate: median | mean— how a metric’s per-run values become one number in the window’s level, theevolvewatch’s bootstrap and the pre-merge check’s movement over its inputs; engine tiermedian.meanis what a 0/1 share (👍, replied, escalated) needs — its median sits at 0 or 1 while the rate moves- Ratings from people —
kind: reported,human: true,scale— a metric a PERSON rates:reported(people only — your 👍 on a dialog, your own 1–5; no judge asked, no code owes it, an unrated run has no value) orjudgedwithhuman: true(the judge scores every run, a person’s rating stands over it).scale: { min, max, step?, labels? }is the scale and rubric both the judge and people answer on — engine default 1–5 with a rubric for a human-rated metric, the judge’s native 0..1 otherwise — normalized to 0..1 on the vector, sotarget/floor/toleranceare written on 0..1. Ratings live inrun_signals(one per person per metric per run, latest wins, median across people) and come through ONE write point that refuses a non-person, a loop replay, a run that is gone or has not ended, an entity whose grading is off, an undeclared metric and an off-scale answer — the whole gesture is checked before a run the sampler passed by is graded on it. Writers: the rating card, the dashboard’s Rate runs (the entity’s newest runs, graded or not, judge and people side by side; on a chat role’s page, Rate on each ended episode), andeval_rate— an agent relaying the person its turn serves (eval:rate, approval by default, so that person confirms it); no agent rates on its own account. The loop reads ratings only in observed windows — trigger, watch, proposer brief, dashboard traces — never in the pre-merge check’s frozen incumbents; the watch reads each human-rated judged metric from ONE source for both windows (people when each holdsevaluation.human.agreement.minPairsrated runs, engine 5; the judge in both otherwise); a run awaiting a person’s rating is not a window member until rated or its wait ends.omnislash doctor→ Loop check warns when areportedmetric is the objective of an entity whose check replays - Rating cards, calibration, earned autonomy —
evaluation.human—ask: never | always | until-calibrated(enginenever): a run’s terminal posts ONE rating card (buttons for one metric on ≤ 8 steps, a number per metric otherwise; the run’s summary, what it published, each metric’s rubric) to the HITL chat and the dashboard, through a keyed job; only a person answers it, and the answer lands inrun_signalskeyed by the card. The carded run waitsexpiresHours(48) before its judge’s number counts alone.until-calibratedstops carding once the judge agrees with people — per judgedhuman: truemetric, over the newestagreement.maxPairsjudge/person pairs under the current judge (model, method, role — a.loop.tsedit keeps it),unknownbelowminPairs,okatthresholdagreement withintolerance— keepingspotCheck(0.1) of runs, and starts again when one falls tolow.loop.autonomy.requireCalibrationholds both auto gates for a person until every such metric isok. The status is visible to yourwindow()hook (ctx.calibration()), on each round’s record, on the dashboard’s Policy view and inomnislash doctor→ Human calibration optional: true— a computed metric its code may not always be able to measure: a livemeasure()or a readywindow()that leaves it out records no observation instead of failing the grade. Refused on a judged metric- Tolerance axes move together — naming either
toleranceortolerancePcton a metric takes the whole band off the engine tier. The two combine as the more forgiving of the pair, so filling the unnamed axis from the default would let the engine outvote the author and let declarations only ever widen a band - Code tier —
<entityId>.loop.ts— a module beside the entity file, discovered by the same sibling convention as*.pipeline.tsand hot-synced through the G-set.defineLoop({ cases, measure, window, shouldOpen, verdict, compare })— ONE module per entity, carrying every brain FUNCTION of the loop and never a VALUE, which is what keeps the dashboard whole: every number stays in frontmatter where the UI edits it, so a custom function costs the surface one row’s provenance and never a hidden knob.measureproduces numbers the engine cannot derive (a delivery rate out of the brain’s own tables, a business KPI),verdictreplaces the pre-merge check’s candidate/incumbent comparator outright,shouldOpenreplaces the trigger’s k-of-n count and answers{ open, reason },cases(workflow scenarios only) are golden inputs every candidate is measured on,windowchooses what a cycle is evidence of (asked on every landing grade and on every scheduled look, beforeshouldOpen; therepairpreset samples the entity’s lasttrigger.windowRunsgraded runs through the grade in hand, and a module composes it withctx.preset.window(ctx)), andcompareis declared for the post-merge comparison. A RUN-scopedcomputedmetric declared outside the engine catalog REQUIRESmeasure— the evaluation fails naming the ids rather than scoring against a metric nobody measured — and a PERIOD-scoped one requireswindow, whose absence is quieter (the id is simply never in a window, a brief or a verdict).omnislash doctor→ Loop modules says so either way, before it costs a run - Brain-wide loop functions —
defineProject({ loop: { window, shouldOpen, verdict, compare } }), function-carried liketools/ingest/engagement. They live in the config file rather than the manifest for the one reason the loop’s other knobs do not: a function has no YAML form.measureis deliberately not among them — it reads ONE entity’s own tables, so there is no brain-wide form of it. Precedence is the engine preset ← project ← entity module, for each independently - Declarations for the common case, code for the rest — code is deliberately not the only form: requiring a TS module to enter the loop would leave most entities outside it, put tolerances and targets out of the dashboard’s reach, and add a file per entity to protect and review
- Three tiers, like every other loop value —
- Eval sampling policy —
evaluation.sampledecides how much of an entity’s execution stream is gradedrate(0..1) — hashed off the execution id so the sample is spread evenly and the same execution always gets the same verdict no matter how many retries or replays askmaxPerDay— a per-entity cap over a rolling 24h, so a chatty scenario cannot starve the rest of the brain;ratealone bounds the fraction but never the count- One shared decision —
decideEvaluationsits at every producer, so nothing is enqueued that the policy would discard - Engine-needed verdicts bypass both — the pre-merge check’s replays and the executions observed while the watch counts a shipped change are always graded, bounded by the loop rather than by the schedule
- Mandatory ceiling for unattended entities —
sample.maxPerDayis optional for human-launched scenarios and required once the engine produces the executions (a scenario withschedule:, and every heartbeat)- Enforced where the money is spent —
decideEvaluationreturnsno_ceilingand grades nothing, so no write path (dashboard config editor,scenario_updatetool) can route around it; deliberately not enforced at frontmatter parse, since a parse rejection would delete the whole scenario over a grading knob - Visible — the entity keeps running and simply is not graded, and
omnislash doctornames every unattended entity in that state under Eval ceilings
- Enforced where the money is spent —
- Booked before queued — a producer that decides to grade inserts the
pendingevaluation row before enqueueing the job, so the row is the reservationmaxPerDaycounts; writing it at worker pickup instead let every execution terminating inside the queue window read the same pre-insert count and pass - Delayed grading —
evaluation.gradeAfterHours— when an entity’s outcome only exists hours after its run (a short’s views at 72h, a delivery rate that settles overnight), the judge waits that long instead of scoring an empty result at the terminal. Three tiers like every other loop value (engine.default(0)→constitution/MANIFEST.yaml→ the entity’s own block), read once at eval enqueue- Only the judge waits — the execution is still sampled and BOOKED at the terminal, so the reservation is still what
sample.maxPerDaycounts and the queue carries the lag as a BullMQdelay; a lag of0passes no delay at all and is byte-for-byte today’s behaviour - A lost delayed job is detectable — the booking records
evaluations.due_at(the instant the grade is owed), and a scheduler sweep fails rows stillpendingpastdue_atplus the queue’s own retry span whose job it has confirmed is gone from the queue, witherror: 'grading job lost'. Elapsed time only narrows the candidates: a worker fleet that is hours behind looks exactly like a lost job from the database, and the incident that drops jobs is usually the one that stops workers, so the queue itself casts the deciding vote. Without it an evicted Redis job would hold the challenger slot the post-merge watch is waiting on forever, and that wait has no clock of its own. (It does not free asample.maxPerDayslot: that count is status-blind and expires with its own rolling 24h window either way.) Rows with nodue_atare never candidates — no lag was in force when they were booked - Two gradings are never delayed — the loop’s own pre-merge replays (they compare a candidate against an incumbent now; nothing waits on a live outcome) and the manual
eval_starttool, which PROMOTES a grading already waiting out its lag rather than silently deduplicating against it omnislash doctorbounds the lag against what the brain keeps — Eval ceilings fails a heartbeat whose lag outlivesagentRetention.ttlHours(the tick is an agent row; once swept, every grade of that entity fails) and warns any entity graded past the 7-day tool-artifact horizon (the judge still grades, it just sees a run with no tool calls); Loop watch warns whenwatch.deadlineHourscannot span the entity’s cadence plus the lag. The engine pins nothing extra for a delayed grade — the remedy is the brain’s own retention number- A replay measures fewer numbers, and only a replay —
EvalRunContext.replayis true when the loop re-ran a frozen input inside its check; a brain’smeasure()may return fewer ids than the entity declared there (the replay posted nothing, so a metric from the world has no source), while a live grading still fails by id on the same omission
- Only the judge waits — the execution is still sampled and BOOKED at the terminal, so the reservation is still what
- Run identity for lineage —
ctx.runRef/context.auth.runRef— every graded execution hands brain code the{ runKind, runId }itsmeasure(run)will later receive, so a late metric can join on rows the run wrote: a pipeline readsctx.runRef(its own invocation, plusdefinitionHash— sha256 of its scenario file and primary*.pipeline.tsas it started, replay-stable), a brain tool readscontext.auth.runRef(the workflow run for a task’s agent, the invocation forcallTool/runAgent, the tick for a heartbeat, the chat episode for a turn of a role’s direct chat)- Inherited by sub-agents and CLI sessions, never by child pipelines — a sub-agent does its spawner’s run’s work; a child pipeline is its own run. Counting toward a run owns nothing: no checkout, no liveness guard
- Chat episodes — the
chatkind’s run — a person’s direct chat with a role is cut into episodes (consecutive turns with one counterpart, first message to end of dialog), each its own run id: every turn joins its chat’s episode at dispatch, whichever surface sent it (telegram, Secretary Mode, dashboard), and its tools see the episode ascontext.auth.runRef; group chats and system roles have none- Idle close —
chat.episode.idleHours(engine 24, MANIFEST §chat→ rolechat:) — a durable per-turn timer counted from the role’s reply, never firing while the role’s agent is busy (a long turn, a turn waiting on the owner); the next turn supersedes the last one’s, so a dialog closes only after that much silence, and a turn after the close opens a new episode. A timer Redis could not take at the dispatch is armed again at the turn’s end and by the previous turn’s timer - Explicit close —
agent_close_episode(theagent.close_episodeop, capabilityepisode:write,hitl: false;{}closes the turn’s own episode,{ id }an agent’s open one — an agent names only itself, a person any) for the role’s agent and the dashboard, andcloseChatEpisode(runId)from@omnislash-ai/infra/chat-episodesfor brain code — one call; an escalation tool records its signal on the episode by run id and ends it - Role version per turn — each turn records the hash of what the role does (its body, its frontmatter without the
evaluation:/loop:/chat:/budget:blocks, its named skills’SKILL.md); a rubric, knob or ceiling edit is not a new version. An episode under two versions counts in neither window of a change.chat.onRoleChange: reset(enginekeep) instead ends the open episoderole-changeand starts the session over on the first message after the change - Graded when it ends — the episode’s grading is booked at its close,
gradeAfterHourscounted from there; an idle close racing an explicit one still books once. Unattended like a schedule: a chat role owessample.maxPerDay(doctor Eval ceilings) - The transcript outlives compaction — folding a long chat’s history into a summary moves its episodes’ turns out of the live history instead of deleting them, so the judge, a later episode’s judge and the dashboard still read them
- Idle close —
- Chat roles in the loop — a role whose
*.agent.mddeclaresevaluation:/loop:is achatloop entity (roster,<role>.loop.ts, its role file walled off from other entities’ changes — its skills stay shared, as a scenario’s do; itschat:block protected like the loop blocks); every per-entity doctor check reads chat roles beside scenarios and heartbeats; system roles are not entities, andomnislash doctor(Loop kind support) says how to move one into a feature- The judge reads the whole dialog — every inbound message, reply and tool call of the episode’s turns;
evaluation.context.priorEpisodes(engine 0) earlier episodes with the same person as labelled context;evaluation.context.maxTokens(engine 40_000) cuts earlier episodes first, then the episode from its middle with an explicit marker - Privacy by one rule —
constitution/Loop/chat-privacy.md(brain-overridable) says how the people in the dialogs are written about: the judge grades under it, and the engine appends it to every chat proposer brief under## Privacy(a proposer pipeline:ProposerBrief.privacy). It binds what they write, not what they read: the proposer’s runs carry each episode’srunId, shape and verdict, and it reads the dialog itself withdb_query(proposer/chat.md§ Reading the dialogs) — a role copy withoutdb:readkeeps dialogs from it - Observed, never replayed — a change is judged on the role’s next episodes begun after it went live under one role version; no standing grant is scoped to a role,
autonomydecides its gates - The owner’s rating —
evaluation.human.ask(enginenever); a chat card shows the episode’s shape and links its dashboard transcript - Episodes on the dashboard — a chat role’s Self-Evolving page lists its episodes (newest first, paged): start → close and why, turns, a span across role versions, the judge’s grade and numbers (its metrics and
measure()’s, e.g.escalated), people’s ratings; Rate on each ended one (rating an ungraded one grades it) and Read the dialog — the role’s agent timeline cut to the episode’s turns, at?episode=<runId>— the rating card links it as/go/role:<role>?episode=<runId>
- The judge reads the whole dialog — every inbound message, reply and tool call of the episode’s turns;
- Grading on request (every kind) — a person’s rating of a run the sampler passed by books the judge on that run (the producers’ booking,
gradeAfterHoursfrom the run’s end) and lands on it, once the whole rating is validated; refused for a run that no longer exists or has not ended, and for an entity whose grading is off. The dashboard’s Rate runs lists the entity’s newest runs graded or not, so any kind’s unsampled run can be rated there - Evaluation data model — one unified
evaluationstable keyed(runKind, runId)with a denormalizedtargetId(the recurrent entity) indexed for the rolling-window count, storing themetricsvector, itsoverallScore/overallPassedprojection, per-task results, the run result, a summary and thecreatedByactor - Every grade records WHO produced it — each
evaluationsrow carries ajudgesnapshot: the model ref it dispatched under, the eval method with a sha256 of the method file’s bytes, a sha256 of<entityId>.loop.ts(null when the entity has none), and a sha256 of the evaluator role — its file’s bytes with the capabilities it resolves to. The same shape the cycle pins onloop_cycles.judge, so the two sides compare like for like. Two grades are comparable only when those match: the difference between numbers produced by different judges is a difference between graders, not between versions of the entity. A row with no snapshot reads as not comparable rather than the same as yours- The recorded ref is the models, not a name — the snapshot’s
modelRefis the entity’sevaluation.modelRefor, when it declared none, the evaluator role’s own inherited ref, with every role step expanded to the concrete chainmodels.jsonresolved it to at the pin. A fallover chain is kept whole (it is what the brain declared its judge to be); a role’s name is not, because re-aiming it inmodels.jsonmid-cycle would change who grades without changing the string. Dispatching the pinned ref reproduces dispatching the role at the moment it was pinned
- The recorded ref is the models, not a name — the snapshot’s
- The judge is frozen onto the cycle — a cycle resolves its judge ONCE at the start (
loop_cycles.judgejsonb), and every evaluation it asks for rides that snapshot: the challengers it launches, and the ticks or invocations it observes. A change to the method file or the loop module reaches the NEXT cycle, never the one in flight. The snapshot is read once the first analysis has created the row and never re-read; a judge that cannot be resolved at all settles the proposalskippedwith an owner notice naming the file — nothing ships that the loop could not measure- Three baskets, not two — graded (
status='completed'), ungraded (status='failed'— the grading itself failed, or some other judge produced it) and not-run (no row at all). Folding ungraded into “the change lost” is how a broken judge reverts healthy work, so the loop counts them apart; the ceiling below is measured over graded + ungraded only, since a challenger that never ran was never put to the judge evaluation.judge.maxUngradedShare— two tiers only (engine.default(0.34)→MANIFEST.yaml§evaluation.judge), no per-entity override: an entity that could raise its own tolerance for a failing judge is the one that most needs the ceiling. Above it the cycle settlesunvalidatedwith reasonjudge unhealthyplus a notice, and nothing is reverted — the batch measured the instrument- A different judge is incomparability, never a re-judge — a grade is comparable only when its snapshot matches the cycle’s on every fact (
judgeMatches, all-or-nothing), and an incomparable grade is set aside whole: a frozen incumbent grade is not used and the check re-runs the incumbent on that input; an observed run after the merge isungradedfor the watch (no slot, counts toward judge health). Nothing is re-scored — the artifacts a re-grade needs do not outlive the run, and a grade re-scored to match the cycle is a bar the loop moved
- Three baskets, not two — graded (
- A cycle freezes WHAT it grades beside WHO grades it —
loop_cycles.frozen(jsonbFrozenSet) is written once, right after the judge: the entity’s lastloop.check.inputsgraded runs through the window’s anchor — the evaluation of the window’s newest run, scannedHISTORY_SCAN_LIMITdeep so a narrow window cannot starve the set; a window naming no runs freezes golden cases only — distinct by input (the engine’s own replays and launched challengers never count as history), plus the brain’s golden cases fromdefineLoop({ cases }), plusbaseSha—origin/<default>as the remote holds it at the freeze. Each history entry carries its own graded run as the incumbent’s score only when that grade was made under the cycle’s judge (judgeMatches); otherwise, and for every golden case, the check re-runs the incumbent on the input pinned tobaseSha— one rule, no knob. Never recomputed: a refinement round reuses it. A set the brain’s files will not yield settles the proposalskippedwith a notice, like an unresolvable judge- The freeze owns the evidence/held-out split — the same scan’s head,
evidence.runsdeep, is recorded asFrozenSet.evidenceRunIds(the runs the proposer was briefed on; the brief selects them the same way through the same anchor, since round 1 is staged before the freeze runs), and thecheck.inputshistory slots fill anchor’s input first (arepairfix is checked on the failure it saw — a regression target, not a holdout), then inputs NO evidence run ran on (FrozenInput.heldOut: true), then shown inputs only when nothing else is left; golden cases are never held out. The check’s verdict reason namesheld-out k of n, andk = 0is a doctor hint under Loop check (check.inputs < 2, or distinct graded inputs ≤evidence.runs). No new key: the seam is the existing pairevidence.runs/check.inputs loop.checkseam —{ mode: replay | none, inputs (1..20, default 5), trials (1..3, default 1), deadlineHours (default 24) }, merged wholesale per tier likeautonomy.modecarries NO engine literal: it is derived per cycle from the plan byresolveCheckMode—replayfor a workflow scenario whose.blueprint.ymlneeds no capability a human has to grant (a replay asks nobody and fails on one), reaches no capability abovemoderate(REPLAY_RISK_CEILING), opens no PR and runs in the brain repo (agent.repoabsent or the brain itself); the facts are read off the plan once, in@omnislash-ai/capabilities(readCheckSubject), for the doctor and the freeze alike;noneotherwise and always for a heartbeat or a pipeline, whatever a tier declares. A declaredreplayon an unreplayable workflow is honoured; undernonethe cycle freezes nothing andfrozenstays NULL — the record that this cycle has no pre-merge check- Golden cases —
LoopModule.cases: Array<{ id, input }>on the entity’s<id>.loop.ts, validated at load (unique non-empty ids, object inputs); workflow scenarios only, the one kind whose execution takes an input omnislash doctor→ Loop check —failonreplaydeclared in an entity’s own frontmatter for a heartbeat or a pipeline, and onreplaydeclared for a workflow with no readable plan (the freeze would skip every cycle);warnonreplaydeclared against a plan the engine would not replay (naming why), on a plan on disk the engine cannot assess (a file that will not parse, an unknown capability — it derivesnoneand checks nothing), and on a replayed entity with fewer inputs to freeze (distinct graded inputs + golden cases) thancheck.inputs;passwith a hint naming replayable workflows that declarednone. Runtime presets and the CLI inject the distinct-input count (countGradedWorkflowInputs), so the doctor package opens no DB connection of its own — and a count that cannot be read from here (no route to postgres) is awarn, never afail.blueprint.ymlis read from config — the read side of the planner’s YAML module (readBlueprintFile,resolveBlueprintYamlPath,@omnislash-ai/config/blueprint-file) moved below the workflow engine so the doctor and the loop’s freeze can assess a plan; the write side stays with the planner, which re-exports the readers
- The freeze owns the evidence/held-out split — the same scan’s head,
- The pre-merge check measures the candidate BEFORE it ships — for a workflow scenario the cycle froze inputs for,
check.ts(S7) runs once the apply PR is open: it pins the PR head (loop_proposals.checked_sha), replays the scenario pinned to that head on every frozen input ×check.trials(the candidates,check_run_ids) and, for every input whose frozen entry carries no comparable grade, pinned tofrozen.baseSha(the fresh incumbents,baseline_run_ids), waits for their grades up tocheck.deadlineHours, checks the judge (maxUngradedShare, candidate side) and that the incumbent was measured at all, then hands the comparator theinputs × trialspairs — each frozen input’s incumbent grade (frozen, or the fresh replay) beside the candidate’s replay of it. Every replay is one journaled step keyed by an id minted outside it; the wait is a backoff poll over the rows the replays leave- The verdict is a commit status on the PR head —
omnislash/validation(packages/git-ops/src/commit-status.ts,gh api … /statuses/<sha>,statuses: writeinENGINE_GIT_APP_PERMISSIONS): pending when the check opens, success onvalidated, failure onregressed, error with the reason onunvalidated. A repository that requires it cannot merge an unchecked candidate, nor a commit pushed after the check (the status is onchecked_shaalone); undermerge: autothe engine additionally arms auto-merge only after the check answered (armAutoMerge, S8), never at the apply, and bound to the checked commit (gh pr merge --match-head-commit, GitHub’sexpectedHeadOid— a moved head refuses the arm); the merge wait (awaitMerge) then holds the PR to that head on every poll, and a head that moved has its arm taken out (disarmAutoMerge) and the round demoted to the human gate with ahead-movednotice - The comparator reads PAIRS —
VerdictInput.pairs, one per frozen input percheck.trials, each carrying the incumbent’s side (its frozen grade, or the fresh replay atbaseSha) and the candidate’s (PairSide:viablewith its vector,failed,ungraded,notRun,unfinished), built by the check from the launch slots it keeps in memory. Two batches on different inputs measure the inputs as readily as the change (proposal a02); a pair measures the change alone regressedcloses the PR —gh pr closewith the per-metric and per-input story in a comment, the row settled fromappliedthrough the same CAS the watch uses (settleCycleValidation(…, 'applied')), a lesson written, no revert: the change never shipped.unvalidatedleaves the PR open for a person, with the round demoted to the human merge gate undermerge: auto; the error status blocks a merge wherever the status is required, so there the person’s decision is an administrator’s bypass or a close, and the notice says so- A broken candidate is named on the first tick — a pinned create that fails before
create-run(its plan will not load at the PR head, the commit will not fetch) leaves no row; the check asks the attempt itself (workflowRunClient.attemptOutcome: live invocation → still driven, else the retained terminal result) and books it as a run the change failed, with the error in the reason, instead of waiting outcheck.deadlineHoursto settleunvalidated - A replay’s grade never opens a proposal — the eval worker looks a graded run up in
check_run_ids/baseline_run_ids(findCycleByCheckRunId), and the trigger window and the baseline both exclude grades booked under the loop’s own actor (evaluations.created_by.kind = loop) rather than by id lists, so a previous cycle’s candidate is never the entity’s history - Stuck detection knows the check’s budget — an
appliedrow whose check has opened (checked_shaset) is bounded bywatch.deadlineHours + check.deadlineHours, since the merge wait starts only once the check answered
- The verdict is a commit status on the PR head —
- Failure taxonomy — a failed run eval carries a small engine-defined
failureCategory(unclear_instructions/missing_capability/wrong_plan/tool_failure/quality_below_bar/other) plus free-textfailureDetail; the proposer’s input and the suggestion card surface other entities recently failing the same way, steering one root-cause fix instead of N point fixes (surfacing only, no auto-suppression) - Live eval progress —
EvalChannelreducesworkflow:eval.*/pipeline:eval.*events into a Redisevaluation:session.snapshot(schema-validated on read, heal-on-acquire reconciliation from the durable rows, active vs terminal TTLs) that chat and the run-detail view render live - Eval events —
eval.started/.completed/.failed/.skipped/.parse_failedon both the workflow and pipeline buses, plus per-taskworkflow:eval.task.completed/.failed - Eval tools (MCP) —
eval_start(grade a finished run now — a workflow or pipeline run, a heartbeat tick, a chat episode; answers the evaluation’s id, refuses a live run, a run that belongs to no entity and one already graded),eval_get(one evaluation in full, by evaluation id or by therunIdit grades, with its live per-task progress while it grades) andeval_list(evaluations newest first with scores and verdicts — by status, the run graded, one entity or a feature, over a window defaulting to 30 days, paged on a cursor); since 1.24.0eval_list_methods/eval_get_method(every evaluation method — engine, customised, the brain’s own — with the entities that grade with it, and one method’s text with the shaeval_write_methodswaps against),eval_list_episodes/eval_get_episode(a chat role’s episodes with the judge’s grade and people’s ratings) andeval_rate(eval:rate, approval by default: a person’s ratings of one run, relayed by an agent for the person its turn serves — through the one door every rating takes);loop_get_cycle(loop:read) tells one improvement cycle whole — its phases, rounds and decisions, PR and revert, verdict and its strength, lesson, agents and spend, itsgradeand each round’s baseline / candidatepairs— as the dashboard’s Self-evolving page and a run’s Evaluation tab read it - Idea tools —
loop_create_idea({ targetKind, targetId, text }→{ ideaId, opened, reason }) andloop_cancel_idea({ ideaId }→{ withdrawn, reason }— the idea’s state after the call, so a repeated withdraw answerstrueagain), in the sameEvaluationcategory and registration as the eval tools, so worker, pipeline, chat and MCP agents all carry them; both provideloop:analyze(moderate,hitl: true— an idea spends a proposer and outranks the entity’s own rule; relaxed per automation with agrants:block or brain-wide inconstitution/engine-capabilities.json). A refused post wrote nothing and is a tool failure with the reason; a posted idea whose cycle did not open yet is a success withopened: false, so the agent does not post a duplicate - Loop — a graded execution that meets its trigger spawns a proposer agent that reads one kind-agnostic brief about the entity — its definition, the window the cycle opened on (
window.json: the stored window, its vector, its runs ranked bad-first), the grade that opened it where one did (grade.json), its recent graded runs through the window’s newest run, its own proposal line, the lessons its settled rounds left behind, its metric declaration and other entities failing the same way — and proposes concrete file changes- The cycle’s anchor is a window, not a grade —
loop_cycles.window(WindowSchema:from,to,runIdsdistinct and oldest first, periodmetrics,reason) is sampled by the loop’swindowfunction through ONE producer (openCycleWindow,@omnislash-ai/loop:enabledfirst, thenwindow→ engine re-filter →shouldOpen, the live-target guard, the keyed submit, never throwing on brain code; its decision half isevaluateTriggerWindow→{kind, window, open, reason, ruleSource}, which a reader outside a grade — the dashboard’s entity detail — asks withanchor: nullfor the tick/owner answer, brain-replacedwindow/shouldOpenincluded) and read by everything after: the brief, the baseline (windowVector— each metric aggregated over the window’s runs, the median unless it is declaredaggregate: mean, with its period metrics laid over), the freeze (scanned through the newest run’s evaluation) and the dashboard’s cycle list. Whatever runs a window names are re-filtered by the engine (windowOutcomes: the entity’s own completed grades, never the loop’s replays; the pinned judge and the watermark where a phase passes them) the drops are counted asrejected, and the row stores the survivors — the anchor a stored window is read through is always a run the loop counted.evaluation_idis the grade that produced the window, nullable; a window with none is analysed on its runs alone, carries nograde.json, and freezes through its own newest run - One bundle, assembled once for every kind (
buildEvidenceBundle) — a target adapter hands over raw material and nothing else; which runs are shown, in what order, with how much of each, and what goes when the whole thing does not fit are decided in one place. While each kind rendered its ownruns.json,loop.evidencecould not mean one thing: a brain raisingrunson a heartbeat and on a scenario was tuning two unrelated documents, and only one of them carried a trace - Bad runs first, and the cut comes off the end —
runs.jsonis the entity’s lastevidence.runsGRADED executions ordered bad-first by the loop’s single definition (isBadRun, under the trigger’s LIVE terms) and then newest-first, each with the judge’s per-criterion reasoning and cited evidence, its metric vector, the execution’s ownresultSummary, the failure taxonomy and — underevidence.includeTrace— its tool calls, read best-effort from the Redis stream so an aged-out trace is honestly[]rather than a failed analysis - The ceiling has a fixed order —
estimateTokensover every file, and overevidence.maxTokensthe traces go first (largest by an order of magnitude, most redundant: the criteria already say WHAT went wrong) and then the oldest runs one at a time down to one; the definition,window.json,grade.jsonandmetrics.jsonare never cut, because a bundle that fits by dropping those has been emptied rather than trimmed - Who analyses and what it is told are seams too — the
loop.proposerblock —{ pipeline, modelRef, instructions }, the one block insideloop:folded PER FIELD (every other block belongs whole to the nearest tier that writes it).modelRef(absent → the proposer role’s own ref, the file that re-aims every analysis at once; nearest tier wins; no effect under apipeline);instructions— lines ADDED to the brief, never a replacement: the brain’s (MANIFEST.yaml) and the entity’s are both kept, the brain’s first, and the engine appends them after whichever brief the cycle reads (the kind’s, the preset’s, an idea’s; the engine template or a brain copy) as## Instructions for every entity of this brain/## Instructions for this entity, and hands them to a proposer pipeline asProposerBrief.instructions: { brain, entity }. The retired flat keysloop.modelRef,loop.promptand a scalarloop.proposerare refused at load with the new path in the message.omnislash doctor(Loop proposer models) fails amodelRefmodels.jsondoes not resolve and warns on one beside apipeline - Two human gates, each declaratively staffed — the Accept card (decide) and the PR merge. By default an owner gets an Accept / Skip card (a reply to it refines) and a human merges the PR the engine opens from the implementer’s gated changes;
loop.autonomy(below) declaresautoper gate. The change only ever ships through a PR — direct-push to main does not exist at any autonomy level - A cycle is its row, so deploys never strand one — each cycle runs as short steps over its
loop_cyclesrow: a step reads the row, does the next thing on the code deployed NOW, writes the row and stops; a wait is a phase on the row (analyze → propose → decide → gate → apply → implement → ship → check → merge → sync → watch → settle → revert → done) plus the instant the cycle must look again. What it waits on rings it when it happens — a card answered or expired, an agent turn ended, the proposer pipeline’s run ended, a PR event, a graded replay, a knob write — and every wait also looks on its own ladder (2 min doubling to 2 h or 6 h), capped at its deadline. Every effect is recorded before it runs and found again on a re-run (the agent dispatched, the card opened, the replays launched, the PR by its branch), so a step a deploy cuts short just runs again. A cycle that keeps failing in one phase shows as stuck after ~45 min and is ended by the engine after a day (unjudged, nothing taught). Read throughloop_status(each entity’s live cycle: phase, since when, next look) and the cycle’s story. Brain-side: self-evolving.md § A cycle, phase by phase - Statuses —
completed(suggestions waiting) →accepted(being applied) →applied(PR open; the pre-merge check runs here) →validating→validated/regressed/unvalidated, plusskippedandfailed. Aregressedreached by the pre-merge check settles the row fromapplied: the PR is closed, nothing shipped, nothing to revert - Prompt overrides — role = folder, file = what it briefs:
constitution/Loop/proposer/{workflow,pipeline,heartbeat,chat,evolve,idea}.md(one rule picks the brief:ideafor an idea cycle, else the preset’s, else the entity kind’s —proposerBriefPath),constitution/Loop/implementer.md, andconstitution/Loop/chat-privacy.md(shared by the chat judge and the chat proposer) are brain files a project can replace whole; engine templates are read as the fallback, never copied into the brain. A brief’s placeholders are{{definition}},{{runs}},{{window}},{{grade}},{{metrics}},{{proposals}},{{lessons}},{{failures}},{{refinement}}(+{{idea}});omnislash doctorfails a brain copy at a retired path (Loop/{default,apply}.md, a flatLoop/<brief>.md,constitution/Improvement/**— Loop prompt paths) or carrying a placeholder the engine does not fill / animplementer.mdwithout{{editablePaths}}(Loop prompt placeholders)
- The cycle’s anchor is a window, not a grade —
- Two presets — the loop is a skeleton plus a bundle of functions —
loop.presetnames the engine tier ofwindow,shouldOpen,verdictandcompare(plus which runs the proposer is shown and which prompt briefs it); a preset carries functions, never values, so switching one changes which rules run and nothing they are tuned torepair(the default) fixes what broke: a bad graded run opens an analysis over the entity’s lasttrigger.windowRunsgrades, the proposer is shown the newest runs bad-first, and the post-merge watch counts bad runs by the trigger’s ruleevolveimproves what works: everytrigger.windowRunsgrades (or everytrigger.everyhours) an analysis opens over the grades landed since the previous cycle looked, nothing being wrong; the proposer is shown the window’s best and worst runs by the objective underconstitution/Loop/proposer/evolve.md; athresholdortrigger.minFailureswritten on the entity (or beside a brain-widepreset: evolve) is refused at load, while a brain-wide one tuned for repair entities is simply not read- The
evolvewatch compares windows — the window the cycle opened on against the firstwatch.windowRunsruns graded since the merge: floors and guards as point rules on the median, the objective through a bootstrap interval atwatch.confidence(engine0.9) —regressedonly when the whole interval is past the tolerance,validatedonly when it clears the bar,unvalidated(“inconclusive”) between; a period-scoped metric keeps the point rule, a run metric on fewer than three observations a side is inconclusive rather than guessed, and a metric unmeasured on either side settles the changeunvalidatednaming it — neverregressed, nevervalidated.MetricDelta.spreadcarries the interval beside the gain whereverperMetricis shown; the watch stamps novalidation_termsunderevolve - An unproven change —
watch.onInconclusive: keep | revert— what a shipped change the watch settledunvalidatedbecomes (a gain inside the noise, a window that never filled, an unhealthy judge):keep(engine tier) leaves it shipped as unproven;revertundoes it through the same revert PR a regression gets, so an autonomous brain keeps only what was shown to work. The row still settlesunvalidated; the dashboard’s cycle view says “reverted by policy”. Not read underwatch.mode: none - Period metrics are owed by
window()— every window that is ready (at the trigger the one an analysis may open on, at the watch the full one the comparator settles on) must carry eachscope: periodid the entity declared, and may carry no other; a missing or stray id is a load error naming it, for the preset’s own sampler too. A window that is not ready may leave them out, so a brain that composes the preset pays for the period read once per cycle and once per watch, never per grade or per poll - A window that waits says on whom — or that it stopped — a
window()that is not ready may name the person’s move it waits on (awaitingPerson: { what, url? }— “score PR #16”): the entity is listed under needs you (loop_statusneedsYou, gatewindow, with the link;next.waits.awaitingPerson; the dashboard’s “Next cycle” line and N need you list), and that is the engine’s only decision on it — no fake evidence, no timeout, under any autonomy. A not-ready window whose stated count (progress.have) has not moved forloop.windowStalledAfterHours(engine 336 h, MANIFEST → entity,0never) is listed as astallwith no brain cooperation; one that names a person is listed only for that. The producer records each not-ready sample (omnislash.loop_waiting_windows), so the fleet reads both without running brain code. Brain-side: self-evolving.md § A window that waits omnislash doctor→ Loop evolve — warns anevolveentity whose only comparable metric is the judge’squality(optimised against its own grader), and hints thequality: { role: observe }opt-out where the judge’s score is still a guard beside a declared objective
- Loop targets are brain entities — the loop’s subject is
(targetKind, targetId)withtargetKindthe ENTITY (scenario,heartbeat,role—LoopTargetKind,@omnislash-ai/types/entity), never the engine that runs it, so a scenario that gains a.pipeline.tskeeps its cycles, lessons, ideas, holds and eval window (evaluations.target_kindis generated fromrun_kind). OneLoopTargetadapter per kind supplies the kind its executions run as now (runKind— a scenario’s engine, read at call time; what picks the proposer brief and whether the check can replay), the entity definition, its resolved loop terms (theloop:block plus the metric declaration the check and the watch read), its graded runs as raw pairs (listGradedRuns→ execution + grade + the agents that carried it, for the grades the preset’s evidence policy chose, never a rendered document) and the auto-accept grant scope - A cycle is a line of rounds —
omnislash.loop_proposals, one row per round keyed(cycle_id, round), under one rule: a round owns its PR and everything that PR carries. The suggestions, their summary, the refinement that asked for them, the decision and who made it,pr_url/revert_pr_url,checked_sha, the candidate and incumbent replay ids and the check’scheck_verdictall sit on the round; the cycle keeps only what it fixes or measures ONCE across all of them — the frozen judge and input set, the watch’s record, the effectivemerge_gateand when the merge wait started, the acks, the agent ids and the stage clock — because at most one round ever merges. A refinement is a CHILD round (parent_id), never an overwrite, so what the previous round proposed is still readable; the parent takes the statusrefined, and afailedround retried goes back toacceptedon the same row- Lookups reach the cycle through the round — the eval worker resolves a graded replay by run id (GIN-indexed), answering
{ cycle, proposal }(a PR’s events reach the loop’s merge wait by the PR’s resource key, not through a lookup); the health predicate for “a promised revert was never opened” readsrevert_pr_urlthrough the same join, and every lifecycle event frame carriesproposalIdbesideprUrlso a reader never has to guess which round a PR belonged to - Live changes announce themselves, not only transitions — beside the lifecycle frames the loop emits
loop:checked(the round’s pre-merge check verdict recorded:outcome,reason,perMetric),loop:watched(each post-merge watch tick:observedruns throughto),loop:revert.settled(how the wait on a regressed round’s revert PR ended:mergedat GitHub’smergedAt,closedorunmerged),loop:merge.gated(the merge gate the apply PR really stands under was recorded —gate: human | autoandwaitStartedAt, the instant a person’s merge wait is counted from) andloop:merge.demoted(an armed auto-merge lost mid-wait —cause: head-moved | disarmed,armStandswhen the arm may still land the PR); the dashboard invalidates on them instead of polling. Observational like every frame — the row is the record
- Lookups reach the cycle through the round — the eval worker resolves a graded replay by run id (GIN-indexed), answering
- One live cycle per entity — a partial unique index (
loop_cycles_live_entity_key) over the live statuses plus a pre-check in the trigger and the analysis, so two failing runs of one scenario can never open two PRs against the same files- The claim always releases — every entry into a live status goes through
claimCycleTarget, which returns null instead of raising when the target is taken; a PR left unmerged past the deadline settlesunvalidatedrather than freezing the entity forever; and a cycle the engine cannot advance — every step in one phase failing for a day (CYCLE_GIVE_UP_MS) — is ended by the engine itself (giveUp: unjudged, nothing taught), so a poisoned cycle never holds its entity forever
- The claim always releases — every entry into a live status goes through
- Recurrence bounds — what the loop may spend on one entity — two
loop:knobs on the same three-tier seam, gated inCycleService.startAnalysisbeside the claim; a refinement round is exempt and an unloadable config fails opencooldownHours— rest after a SETTLED cycle, engine tier conditional on the outcome (DEFAULT_ANALYSIS_COOLDOWN_HOURS: 6h ordinarily, 24h afterregressed) and so carrying no Zod.default();0disables the restmaxAnalysesPerDay— rolling-24h ceiling on proposer agents for one entity, engine default 3,nullopts out;omnislash doctor→ Loop ceilings names every unattended entity that took the opt-out- Only an analysis that RAN counts — both bounds filter on
startedAt IS NOT NULL, so a refusal neither fills the budget that refused it nor extends the cooldown that produced it - Refusing is loud — a refusal settles the cycle’s already-born row
skippedwith the reason (CycleService.startAnalysis), so the entity’s cycles on/evolutionshow the refusal and its reason instead of the loop going quietly silent
- Per-run spend ceiling —
loop.budget—{ maxUsdPerRun }on the same three-tier seam as every other loop block, merged wholesale per tier likeautonomy, engine defaultnull(unpriced, so an existing brain changes behavior by zero). The recurrence bounds above price how OFTEN a run may open and say nothing about what one costs; the ceiling covers the whole run — every agent it opens across every round — and is enforced by SUBTRACTION:readBudgetAllowanceprices the run’s own agents (loop_cycles.agent_ids, off the spend ledgerusage_records) before each dispatch and hands that agent the remainder asAgentCliOptions.maxBudgetUsd- A run with nothing left opens nobody — the analysis and the apply both refuse before creating an agent, and the stage’s failure names what was spent of what. Enforcing per agent instead would multiply the declared number by however many turns the run happened to take (two per round, six for a run refined twice and retried once), which is not a ceiling. A retired agent still counts —
deleteAgentis a soft delete and the ledger is keyed on the agent id — and the cost of the turn just finished is already committed, because the VO settles the loop’s callback downstream of the finalize stage that drains that insert. A turn that never reached a terminal is the honest blind spot: that spend happened and no row records it - Two enforcements, one outcome — a harness with a native budget option (the Claude SDK’s
maxBudgetUsd) stops the turn the moment it crosses and itserror_max_budget_usdterminal is namedover budget; Codex and OpenCode, which accept no such option, are compared againstsumAgentCostUsd([agentId])once the turn’s terminal passes and the terminal is rewritten into the identical failedover budgetone. A caller reads one outcome instead of branching on which harness it dispatched to - The second lane is a verdict, not a brake — the spend has already happened when it fires, and the ledger it reads excludes the turn that just ended (the finalize stage writes that row downstream of the comparison), so it bites on the turn AFTER the one that crossed. The native lane is the one that can actually stop a crossing turn; the doc says so rather than promising a cap the harness cannot keep
- A run with nothing left opens nobody — the analysis and the apply both refuse before creating an agent, and the stage’s failure names what was spent of what. Enforcing per agent instead would multiply the declared number by however many turns the run happened to take (two per round, six for a run refined twice and retried once), which is not a ceiling. A retired agent still counts —
- k-of-n trigger, never one run — an analysis opens when the graded run that just landed went badly AND
loop.trigger.minFailuresof the entity’s lasttrigger.windowRunsGRADED evaluations did (engine default 2 of 5, the block merged wholesale per tier likeautonomy). One execution is a sample of the entity’s noise, not a measurement of it: a proposer opened on a single off run spends a model, a human answer and a PR chasing variance, then ships a fix whose validation compares the entity against that noise again.minFailures: 1restores “analyse the first failure”,minFailures == windowRunsis the “N in a row” rule, andminFailures > windowRunsis a schema error rather than a loop that silently never fires- Ungraded runs are not in the window — the window is filled from
status='completed'rows only. A run the judge failed on was never scored, so it is neither good nor bad; counting it as bad opens analyses on a sick judge, and merely letting it occupy a slot is worse, because the window is a FIXED size and an ungraded row would push a real graded run out and quietly demand more failures than the author asked for - What makes a run BAD is unchanged —
loop.thresholdlimits analysis to failed or below-threshold executions (omitted → every graded execution is bad, so k-of-n then only withholds the entity’s very first analysis); a run with no score counts as failing, since no evidence of health is not evidence of health shouldOpenreplaces the rule, not the numbers —LoopModule.shouldOpen({ window, trigger })on the entity’s<id>.loop.ts, ordefineProject({ loop: { shouldOpen } })brain-wide, answers in place of the engine’s count and receives both the window and the terms the count would have used.loop.enabledis settled before it runs: a kill switch a rule can switch back on is not a switch. The post-merge watch settles by the same rule (triggeredWithin,@omnislash-ai/loop): asked once per landed grade of the observed window, over the runs since the merge newest first, the way the trigger asks it — a yes at any grade regresses the change, a full window without one validates it, and a rule that throws settlesunvalidatednaming it.LoopTerms.functionscarries all five resolved functions with the tier behind each (loopFunctionsFor,@omnislash-ai/config/loop-config);source: 'engine-preset'is the preset’s own count. A rule answers{ open, reason }, never a bare boolean — the reason is what the owner is shown for the analysis that did not open- Metric targets count too — a declared
targeta run did not reach makes that run BAD in the window, even a run that passed. “Quality is fine but cost is still double what I asked for” is exactly the case a pass/fail gate cannot see - The newest run must be bad, not just the count — the count alone can fire on a HEALTHY run (two old failures still in the window), and the window’s newest run is the anchor the proposer’s evidence and the frozen history are read through (
findGradedWorkflowInputsreadsthrough:its evaluation, bounded on(createdAt, id)so a sibling booked in the same millisecond cannot stand in for it). Opening on a good run asks the proposer to fix a success, so the window is only ever a brake: it withholds analyses one-bad-run would have opened and opens none it would not - Brain code cannot fail the grade — resolving
shouldOpenimports the entity’s<id>.loop.tson the success path of every graded run, so an unreadable module withholds THIS run’s analysis (logged) instead of throwing after the evaluation was written and announced, which would bury the job and re-send its report on every retry - A cadence is the SECOND producer —
loop.trigger.every— hours between scheduled looks at an entity, with no engine default because the key’s ABSENCE is the meaning (only a landing grade opens a window, which is the usual answer). No rule over graded runs can express “every Monday” or say anything about a week in which the entity did not run — the producer is the grade, so that is a producer problem and not a seam. An hourly scheduler sweep (LOOP_TICK_SWEEP_INTERVAL_MS,apps/scheduler/src/loop-tick.ts) walks every entity whose resolvedeveryhas come round since its own KV mark (loop-tick:<kind>:<id>; read, stamped and projected by@omnislash-ai/loop’s cadence module —readTickMark,nextAskAt= mark +everyrounded up to the sweep, andrestingUntil= last settle + the cooldown — so the dashboard answers “next look” off the same clock) and calls the ONE producer withanchor: null,producer: 'tick'and the entity’s LIVE trigger terms; everything else —enabled, the window,shouldOpen, the live-target guard, the keyed submit — is the producer’s, exactly as for a landing grade. The mark is stamped when the producer ANSWERS, opened or declined (a decline has answered the cadence’s question for this period; re-asking it an hour later would collapseeveryinto the sweep’s own cadence), and NOT when the engine’s own read threw, which makes the next hour the retry. It is the cadence clock alone and never the loop’s mark: a tick that opens nothing writes no row, somax(window.to, watch_window.to)does not move and the next window is sampled as if the look had not happened. The sweep’s hour is the floor on how fine a cadence the engine can honour — a smaller number is not refused, it gets the sweep’s own cadence, andomnislash doctornames that rather than letting an author find out from a number that quietly rounded up. Two scheduler replicas reading the same stale mark both ask, and the loser is settled by the live-target guard plusloop_cycles_live_entity_keyrather than by a redis lock — one live cycle per entity is an invariant the row already holds. Underrepaira scheduled look still needs the entity’s newest graded run to be bad: the preset is a rule about failures, so a clock changes only WHEN one is noticed - Frozen for workflow scenarios — the trigger (
enabled+threshold) is baked into the plan when the plan is built, so editing it affects runs planned afterwards, not runs in flight; pipelines and heartbeats read it live.trigger:itself is NOT frozen for any kind: it is a measurement over the ENTITY whose count spans runs from many plans, so freezing it would tie the window to whichever plan produced the newest run. The measurement knobs are never frozen — though each deadline is read once per PHASE rather than once per wait:reviewDeadlineHoursis read at each analysis round and arms every one of that round’s human waits at once (and a brain proposer’s run),watch.deadlineHoursonce the change has shipped, arming the merge wait and the challenger window together. So an edit reaches the next round or the next proposal, never a wait already counting down — a proposal already past its analysis runs to a verdict on the terms it started with, never half on the old number and half on the new
- Ungraded runs are not in the window — the window is filled from
- The ideas bucket — a human’s hypothesis, next in line —
loop_ideasholds ideas posted for ONE entity; the entity’s next cycle is opened on the oldest queued one (FIFO, one idea per cycle, no priority, no config key) and works it before anything the loop would propose, and every stage after the analysis is unchanged, so an idea comes back as a measured experiment with a verdict. Engine entry pointspostLoopIdea/withdrawLoopIdea(@omnislash-ai/loop) are the only way in or out, so every surface refuses the same things in the same words — the/ideachat command, theloop_create_idea/loop_cancel_ideatools and the dashboard’s Ideas panel on/evolution(the bucket with each idea’s derived status and queue position, what the head of the queue waits on — the producer’s own dry run, the same sentence the entity’s timeline ends in — withdraw, and on a cycle the idea it was opened on)- Two surfaces —
/idea <entity> <text>in chat (<kind>:<id>or a bare id resolved against the loop’s roster, asking for the prefix when it names two kinds; the reply renders the outcome in one sentence and carries a Withdraw button while the idea is queued), and theloop_create_idea/loop_cancel_ideatools (see Idea tools above) — which is how an agent routes a brain-wide idea to the entities it concerns - The author hears back — when the cycle that took an idea settles, its author gets the outcome, the idea quoted, and the measured deltas (the lesson’s own numbers, watch before check) or, for
skipped/failed, the proposal’s summary (where a declining proposer says why) or the error. A chat post is answered in its chat; an agent or MCP post in the chat the loop’s cards arrive in. Sent at exactly the terminals a lesson is written for, best-effort in one journaled step - Outranks the rule, never the gates — a queued idea steps past the window’s
ready,shouldOpen(which is simply not asked, so a brain rule written before ideas cannot starve them),cooldownHoursandmaxAnalysesPerDay;loop.enabled(the post refuses a switched-off entity), one cycle at a time (an idea waits until the entity’s previous cycle has SETTLED — an unanswered proposal holds no claim but still holds the line, so ideas run strictly one after another, each proposer seeing the previous outcome), the breaker, the pinned judge, the frozen set, the protected surface, the edit-size bound, the check, the watch and the decide gate all hold. The exemption keys on the input’sideaId, never on which producer asked - No fourth producer — the ONE producer reads the entity’s queued head itself, whoever called it: a post pokes it as
request, the scheduler’s hourly sweep asks astickfor every enabled entity with a queued idea (one query per sweep, notrigger.everyneeded, no cadence mark stamped), and a grade that lands while an idea is queued opens the idea’s cycle with itsgrade.jsonriding along. The cycle is keyed<kind>:<id>:idea:<ideaId>:<instant>: producers racing one idea can only open one cycle (one holds the entity, the rest step aside spending nothing, and only one can take the idea), and an ask that stepped aside does not use the idea up - The binding lives on the idea —
loop_ideas.cycle_id(UNIQUE,ON DELETE RESTRICT); taking the idea (after the cycle claims the entity) and withdrawing it are two compare-and-sets on the same row, so exactly one wins, and a cycle that lost the race settlesskipped(“the idea was withdrawn before its cycle started”) without spending a proposer, a slot of the day’s ceiling or a cooldown. Status is derived, never stored:withdrawn/queued/ the bound cycle’s status - Its own prompt, never trimmed — an idea cycle is briefed with
constitution/Loop/proposer/idea.md(engine default, brain-overridable) whatever the entity’s kind or preset — the entity’sloop.proposer.instructionsstill appended — plusidea.mdin the brief: the text, who posted it and when, never cut by the evidence budget and bounded at the post byIDEA_MAX_CHARS(4000) for that reason. Declining — protected surface, missing capability, a validated lesson against it — is emptypromptChangeswith the reason insummary; the output schema is unchanged
- Two surfaces —
- Two deadlines, not one —
loop.reviewDeadlineHours(168h) bounds HUMAN latency at each wait of an analysis round (none of which exists underautonomy.decide: auto, where a grant request fails the turn and a broken analysis or apply closes the cyclefailedwith no card), all armed from one read per round: a grant request the proposer parks on abandons the turn and records the analysisfailed; an Accept / Skip card nobody answers inside it expires (the card’sexpiresAtIS the deadline — one clock) and the cycle settlesskippedunanswered, with areview-abandonednotice, rather than holding the proposal forever; a grant request the implementer’s turn parks on expires the same way, but abandons the turn and records the applyfailed, with the error on a Retry / Skip card that the same number bounds in turn.loop.watch.deadlineHours(168h) bounds EVIDENCE latency. One number for both would mean a brain tightening its verdicts to four hours had also, without saying so, given its owner four hours — discarding every proposal sent on a Friday evening - Closed-loop validation — before the merge a workflow candidate is COMPARED by the pre-merge check; after it every kind is WATCHED by the
loop.watchblock (mode: observe | none,windowRuns/minFailuresdefaulting to the trigger’s pair,deadlineHours168), merged wholesale per tier likeautonomy— no per-kind strategy type, the seam is the whole answer- The verdict is an ORDERED list of rules over the PAIRS (
metricVerdict, the pre-merge check’s comparator) — (a) fewer thanceil(inputs × trials / 2)pairs judged — the incumbent graded and the candidate answering: graded, or launched and dead before a grade — →unvalidatedinsufficient evidence; (b) the judge-health ceiling, settled by the loop upstream; (c) viability per input — the candidate must have passed something, and among the inputs the incumbent passed it must keep a strict majority (an input the incumbent failed too is not a loss; passing it is the fix), ties conservative; (d) acriticalmetric past tolerance on ANY input →regressed, naming the input — the per-input veto the median cannot forgive; (e) aguardpast tolerance on the median of its per-input movements →regressed, a MECHANICAL VETO checked before the objective precisely so it outranks any score the judge produced; (f) any metric below its declaredfloor→regressed; (g) the objective past tolerance →regressed, and undermustImprovea gain inside the noise across inputs (half the spread of the per-input movements, never tighter than the tolerance) →unvalidated; (h) otherwisevalidated. Each rule answers a question the next one is not equipped to ask, and every rule below the one that fires would have answered the same batch differently.VerdictResult.perInputrecords every pair — what each side produced, whether the candidate lost the input, which critical metrics it failed there — besideperMetric - The four buckets travel whole to the comparator —
failed/ungraded/notRun/unfinishedplusexpected(how much evidence was PROMISED), instead of one summedinviable. Summing them was the loop deciding on the comparator’s behalf that a dead run, an errored grade and a run still going are the same evidence — which is how a batch nobody could measure reachedregressedand opened a revert PR.unfinishedandnotRunare never counted against a change; they only make the graded count smaller, which is exactly what rule (a) measures - Every metric is the MEDIAN of its per-input movements — the candidate’s value on an input against the incumbent’s on the SAME input, in the metric’s direction, over the pairs where both sides measured it (
MetricDelta.compared); the two side medians ride along for reading. Tolerance is taken at the incumbent’s median (and, for acriticalmetric, at each input’s own incumbent value). The comparator receives the pairs RAW, not pre-aggregated, so a brain replacing it (defineLoop({ verdict })) can apply its own statistic — a worst case, a p95 — to the same pairs - Judged metrics get a tolerance band — default 0.05 absolute on a 0..1 judged metric, 10% relative on a computed one, both brain-tunable and per-entity overridable. An LLM judge’s own noise is larger than a strict
>=could tell from a real regression - The watch’s window defaults to the trigger’s — the loop has no rolling baseline. The evidence a COMPARISON needs is sized where the comparison happens, before the merge (
check.inputs×check.trials); after the merge the watch counts overloop.watch—windowRunsruns,minFailuresbad ones — whose pair is folded fromloop.triggerunless the brain declares it, so an entity that would trigger again inside its window is one whose change did not hold, and a brain that wants a longer watch than trigger writes the block.omnislash doctorwarns under Loop watch when an OBSERVED entity (a heartbeat, a scheduled pipeline) has a cadence that cannot deliverwatch.windowRunsexecutions insidewatch.deadlineHours;watch.mode: noneis exempt mustImproveon the metric, not a knob on the loop — off by default, because holding an inert change to a higher bar than “it broke nothing” would fight the human who chose to merge it. Declared on anobjective, it settles a change that moved nothingunvalidatedrather thanvalidated, and the noise band it compares against is half the spread of the per-input movements over the compared pairs (never tighter than the metric’s tolerance, and falling back to the tolerance below three pairs, where a spread is not yet a distribution). A baregain > 0test adopts noise as progress on its first flaky day. Read only on the objective, and deliberately INERT elsewhere rather than a load error — the engine itself demotesqualityto a guard when another metric becomes the objective, so a sibling’s role change would otherwise start failing a declaration that was legal when writtenflooris the absolute bar a tolerance cannot state — per metric, optional: withdirection: maxthe challenger median may not fall below it, withminit may not rise above it. A tolerance says how far a metric may move from where it was; only a floor says where it may not stand at all, which is the only way to hold a promise made outside the loop (an SLO, a pass rate a customer was told about) — ten changes each 4% worse are ten validated changes and a metric a third below where it started. Reported separately fromregressedon the delta, because a metric can sit below its floor without having moved at all. Read onobjectiveandguardand inert onobserve, which is defined as “never part of a verdict”observe(every kind) asks ONE function per tick and stores its record — after the merge the engine starts nothing: it stamps a watermark when the change goes live and, on every tick, asks the loop’swindow(phase: 'watch', since the watermark — underrepairthe firstwatch.windowRunsruns graded under the cycle’s pinned judge; a tick the judge failed on fills no slot and counts toward judge health, the loop’s own replays never count), re-applies the engine’s filters to whatever it named, writes that window on the cycle (watch_window— the eval-forcing producer and the dashboard read the window’s progress off it), and asks the loop’scompareover the cycle’s window against it.nullis “not yet” and the watch keeps observing; aVerdictResultsettles the row on the spot and is stored verbatim aswatch_verdictbesidevalidation_run_ids(the settled window’s runs) and, underrepair,validation_terms.repair’scompareis the trigger’s question (triggeredWithin): by the engine’s count of bad runs — the trigger’s own definition (isBadRun: failed, belowthreshold, or a missed metrictarget) — or by the brain’sshouldOpenwhere the entity declares one (the resolved function’ssourceis what tells the two apart), asked at every grade.minFailuresbad runs settleregressedthe moment they are counted, whatever the rest of the window brings (withheld only while the judge looks broken — the engine’s own read, which settlesunvalidatedat the deadline instead); a full window with fewer settlesvalidated; a window the deadline closes short of both settlesunvalidated— with an emptyperMetric, which is how the autonomy breaker reads arepairunvalidatedas blind (a comparator that DID measure and found no gain is not blind). Brain code runs inside the journaled tick step (60 s inactivity timeout) and a throw fromwindoworcomparesettles the rowunvalidatednaming the function — never a killed invocation. The check’s record stays on the round (check_verdict); the lesson reads the watch’sperMetricfirst, then the check’s. Post-merge runs are on the entity’s own inputs, nobody’s pair, and comparing them against its history would measure the inputs (proposal a02) — which is whyrepaircounts. Re-running a heartbeat would send real messages, re-running a pipeline is arbitrary brain code whose side effects the engine cannot reason about, and a workflow’s like-for-like measurement already happened before the merge (the pre-merge check, above). While the window is open the target’s executions bypass sampling, since an ungraded execution leaves the window short.launchis gone fromValidationStrategy: nothing is re-executed after a mergewatch.mode: noneis declarable — the honest answer for an entity the brain would rather not watch. A change it ships settlesunvalidatedby design, with anunvalidated-by-designnotice — explicitly “not checked”, never mistaken for “checked and fine”unvalidatedis a real terminal, and the comparator may now reach it — a watched window the deadline closed short with fewer thanminFailuresbad runs, an unmergeable PR past the deadline, a non-re-runnable entity, an unhealthy judge, or a comparator that read the evidence that DID arrive and found it too thin to tell a real move from noise. Partial evidence is not upgraded to a verdict in either direction, and no outcome butregressedopens a revert.MetricVerdictFnreturns a discriminated union —validatedcarriesreason: null, the two negative outcomes owe astring— so a brain comparator cannot ship a revert order with nothing after the colon- Deadline —
loop.watch.deadlineHours(default 168h) bounds this wait — the evidence one, not the wait for a human answer — with the clock starting when the change went live rather than when the PR was opened; the merge wait it also bounds starts once the pre-merge check has answered, so the check’s owndeadlineHoursis never charged against it - Automatic revert —
regressedopens a mechanical revert PR (merged through the same gate the change shipped under —autonomy.merge: autoarms auto-merge on it too, else a human merges), whose URL lands on the round’srevert_pr_url— a field of its own, becausepr_urlis the change that shipped and both the revert and its credential mint read it as such. The run workflow then follows the revert PR on its own wait (its own subscription to the revert PR’s events + poll, bounded bywatch.deadlineHoursfrom the revert’s opening) and recordsrevert_outcome(merged/closed/unmerged) and GitHub’srevert_merged_at; while it waits, the producer opens no new cycle for the entity (grade, tick and owner alike — waiting on the revert of …), so the next analysis never reads a brain that still carries the regressed change. Undermerge: autothe revert auto-merges and the hold ends on its own; a closed or overdue revert lifts it with the change still live - The verdict is persisted, not recomputed — the check’s settle writes the comparator’s own record (
loop_proposals.check_verdict, on the ROUND whose PR it measured: per-metric incumbent median → candidate median, the median per-input movement, tolerance allowance, pairs compared, which metric tripped; per-input sides, losses and critical vetoes; and the reason) plus the tally (challengers_viable/challengers_total— viable candidate replays of the pairs promised for the check, good runs of the window for the watch) in the SAME statement as the status, so a CAS loser can never overwrite the winner’s evidence. Tolerances, metric roles and even the comparator are brain files that move — re-deriving a past decision from today’s declarations would explain it in terms nobody used to make it. The tally is recorded even where no comparator ran: “1 of 5 ticks observed” is the whole explanation of anunvalidated, and an execution that never happened leaves no run id to count. The dashboard renders a row’s LATEST stage as its verdict: once the watch has opened (the watermark is stamped),loop.list/getById/loop.get’scyclesattachwatch— each observed run asgood/bad/ungradedunder the cycle’s pinned judge and the trigger’s terms (isBadRun, the watch’s own rule, not the pass column) with the window and the count that regresses — and that counted window headlines the card, chip and panel, with the check’s stored record shown beneath it as the pre-merge stage. A window still counting is read under the entity’s live terms, as the watch reads them on every tick; a settled row is read under the terms its settle was made under (loop_cycles.validation_terms, written by the watch in the same statement as the tally), so awindowRunsorthresholdmoved afterwards never relabels the evidence that closed it. A row the watch never reached shows the check’s record alone - Stage clock —
proposed_at/applied_atbesidestarted_at/completed_at, each named for the stage it closes, so time-to-propose, time-to-apply and time-to-verdict are subtractions in SQL.completed_atalone could not carry them: it means “this row is done” and is rewritten by whichever transition finished it last, erasing every earlier boundary
- The verdict is an ORDERED list of rules over the PAIRS (
- Protected surface, applied before the write — one resolved
ProtectedSurface— the turn’sPathConfinement { editable, denied, unreadable }plusmaxChangedLines— per implementer turn, rendered in three places: the CLI harness’s native permissions (compiled from the implementer role’sfile:read+file:writegrant plus the surface — an edit outsideeditableis refused before it happens; Claude natively per path, Codex only as a writable root — its paths and read wall hold through the sandbox and the gate — and OpenCode not at all, soomnislash doctorfails an implementer role on it), the bwrap jail’s deny / mask mounts plus the hiding of every other copy of the brain on the host underisolation.enabled, and the diff gate before the commit. The proposer is told to say so in its summary if a criterion is genuinely wrong- Allow-list by default —
editableis the accepted proposal’s owntargetfiles plusskills/<newSkill>/**; new files are allowed only under it.loop.editablePaths(entity frontmatter, globs) widens it for one entity — never brain-wide - The engine floor, add-only —
deniedisconstitution/Evaluation/**,constitution/Loop/**,constitution/Agents/system/**, any*.loop.ts,constitution/models.json,omnislash.config.ts,memory/and every declaredmemory.sourcesentry (memory has one writer, the memory tools — the loop never writes it), the deploy perimeter (db/,.github/,docker/,Dockerfile,Dockerfile.override,.dockerignore), the CLI agents’ config at any depth (.claude/,.codex/,.mcp.json,opencode.json),.git/, plusloop.protectedPathsfromMANIFEST.yaml, which ADDS and never removes;deniedbeatseditable - The judge is closed to reading too —
constitution/Evaluation/**and*.loop.tsareunreadable: the proposer is not handed the rubric it is optimized against, and works from the per-criterion feedback on the evaluation instead. A wall on files: the proposer’sdb:readreaches the judge’s recorded turns - The proposer reads the runs itself —
loop/role-proposer.mdgrantsfile:read,db:read(db_query,db_schema) andloop:read(loop_history): the evidence files start a round, and the proposer reads whatever run, conversation, tool call, step or brain table explains a verdict; on a chat role it reads each episode’s whole dialog by itsrunId. A brain copy withoutdb:readconfines it to the evidence files. Widening is the brain’s call: the proposer’s cwd is the LIVE brain, so a resolved grant (role-general.mdincluded) carrying'*'orengineering:executehands it a shell that reads the judge’s files and writes outside any diff —omnislash doctor(Loop confinement) warns, as it does for a resolved'*'or shell on the implementer role - A bound on how much, not only where —
loop.maxChangedLines(engine 600, three tiers: engine,MANIFEST.yaml, entity frontmatter) is counted at the diff gate over what the engine would commit: added plus removed lines across every gated file, a created or deleted file counting its whole length. A binary file is refused outright rather than counted — the implementer writes the bytes the gate reads, so any “content shaped like X counts zero” rule is a switch the implementer holds. Past it the apply isfailednaming the count, the bound and the three largest files — never a truncated PR and never a partial landing; there is no per-file bound and no warn-only mode. It is the bound an optimizer rewriting the same artifact every cycle needs: GEPA’s prompts grew 75% and got worse without one, and the end state has a name, “context collapse”. The apply prompt states the number so the agent plans inside it;omnislash doctor(Loop confinement) warns when the bound sits below a file the loop actually edits; and since the key lives in theloop:block the gate compares before and after, the implementer cannot raise its own bound - By block — the
evaluation:/loop:/chat:/budget:/gate:frontmatter of an entity file and the matching sections ofconstitution/MANIFEST.yaml, compared before and after at the gate (budget:is the money ceiling the entity’s own agents run under — budgets.md;gate:a heartbeat gate’s timeout, which could fail every tick before it is graded). This is the one that matters once metrics exist: the objective, its target and its tolerances live inside the very file the implementer is supposed to rewrite, so it edits the body and the rest of the frontmatter freely and those blocks not at all - Git belongs to the engine — the implementer only edits files and reports
{ summary, files }; it holds no git credential, noghtoken and no shell (nothing is minted for a confined turn and the harness strips the engine’s own from the child env). The engine snapshots the tree before the turn — every dirty path with a content digest, plus the checkout’s.git/configandHEAD(pre-existing dirt in a live checkout is never the agent’s; a rewrite of an already-dirty file still is), gates what changed (surface, regular files only — a symlink never lands — measurement blocks, git metadata unmoved), copies exactly those files into a throwaway clone of its OWN repository (the brain baseline; in dev the live checkout, once its metadata is known intact) — each copy held to the digest the gate read the file under, so a file that moved between the gate step and the landing step fails the apply rather than landing uninspected — commits them onloop/<entity>-<hex>cut from the exact base commit the gate read (not a tip that moved after it; a path under a directory symlink the base tracks is refused, never written through), force-pushes under the deploy’s credential (one step) and opens the PR (the next). The one git command that runs in the agent’s tree isgit status, with hooks off.omnislash doctorfails an implementer role whose resolved grant carries'*'or a shell. A violation fails the apply with the paths on the Retry card — nothing committed, no PR to close
- Allow-list by default —
- Lessons are rows, not prose (compounding) — every terminal round that had a proposal behind it writes one
omnislash.loop_lessonsrow: the hypothesis it ran on (the round’s summary, or a proposer pipeline’s own hypothesis), the files it proposed to touch, the failure taxonomy of the evaluation the cycle answered, how it ended and — from the round’scheck_verdict— how the metrics actually moved. Mechanical, no LLM call: every part was already distilled by the proposer or by the judge. The lessons used to be markdown written into agent memory and recalled by vector search, which made the plainest question a proposer can ask (“what was tried here before, and how did it end?”) answerable only if an embedding agreed, and left the dashboard parsing a heading back out of a memory line- All five terminals teach —
validated,regressed,unvalidated,skippedandfailed; the three the markdown lesson never wrote are exactly the ones a reader asks about. A terminal with NO round behind it (an analysis that produced nothing, an unresolvable judge, a cooldown refusal) writes none — there is no hypothesis to record. Ordered by the ENTRY handler at every terminal it reaches, not by the stages, and idempotent on(proposal_id, outcome)so an at-least-once step replay cannot double the archive while still letting a retriedfailedround settle a second, different way - Citations make a lesson measurable — the proposer answers with
citedLessons, ids out of thelessons.jsonit was handed (required in the structured-output schema; the empty array is the honest “stood on none”), and the same step that writes the settle credits each cited lessonhelpfulon avalidatedround whose verdict something measured (strengthpairedormeasured— a blind win credits nothing) or charges itharmfulon aregressedone, in SQL so two rounds citing one lesson at once both land. An id naming no row moves nothing — a hallucinated citation must not fail the settle carrying it - A person’s “no” is not flattened into
skipped— each recalled lesson carries itsrejection, derived from the round and cycle rows (rejectionOf):human(the round skipped and decided by a person, with the note the dashboard’s Skip asks for —loop_proposals.decision_note, riding the approval tap asnote) ortimeout(the round still proposed in a cycle settled skipped: nobody answered the card); the loop’s own set-asides (a lost claim, an unpinnable judge or input set) record the round decided by the loop and are neither. And its verdictstrength - Recall and retention are one seam —
loop.lessons(maxRecalled10,retentionDays180), replaced wholesale per tier likeautonomy, read live at the moment each applies.maxRecalled: 0recalls nothing, so there is no parallel on/off key; recall puts what a person turned down first, then newest first, bounded after that ranking (recallLessons), and never ranks by relevance, because which of an entity’s past failures still matter is the brain author’s judgement. Expiry is one dated DELETE at the BRAIN tier on the scheduler’s loop sweep (every 5 minutes) rather than a sweeper entity of its own
- All five terminals teach —
- Verdict strength —
paired/measured/blind— one derivation (verdictStrengthin@omnislash-ai/loop) of how strongly a shipped change’s verdict was measured, off the cycle’s watch record and its merged round’s check:paired= the pre-merge check passed and the watch measured;measured= the watch alone measured (a per-metric reading, orrepair’s k-of-n count with its stampedvalidation_terms);blind= nothing did (no record, a count that closed short, a watch a sibling’s change or a foreign edit confounded) — exactly the breaker’s “unmeasured”. Read by the breaker, the lesson’shelpfulcredit,loop_historyand the dashboard (each settled outcome shows it) - Cycle grades — Improvement / Tradeoff / Tie / Inconclusive / Regression — one derivation (
cycleGradeOfin@omnislash-ai/loop, wordsCYCLE_GRADESin@omnislash-ai/types/cycle-grade) of how a settled cycle’s experiment came out, off the watch’s verdict record and its strength in the loop’s own terms (each metric’s tolerance and interval — no new number) — or, for a candidate the pre-merge check regressed, off the check’s record;blindreads Inconclusive, a cycle with no verdict reads no grade. Qualifies the status (a Reverted cycle may be a Regression or a Tradeoff), never stored, always shown with its n. Decision 0090. - Your own proposer —
loop.proposer.pipeline— one of the brain’s pipelines writes an entity’s changes instead of the proposer agent (engine defaultnull= the proposer agent; brain-wide inMANIFEST.yaml; per entity in frontmatter, wherenulltakes the proposer agent back). The loop SENDS itsrunhandler a typedProposerBrief(keyed once per round; the pipeline’s run row is the answer, read when the engine rings the cycle at the run’s end or at the cycle’s next look) — the proposer agent’s own evidence as JSON (the entity’s definition, window, opening grade, runs, metrics, lessons, proposal line, related failures — every document the proposer agent reads — and theloop.proposer.instructionsof both tiers), cut by the sameloop.evidence/lessons.maxRecalledbounds, plus the rails its files will meet — as a loop-launched run of its own, and it answers{ summary, hypothesis, files: [{ path, content | null }] }ornull/ nothing (“nothing to propose” → the cycleskipped, no round). The answer is parsed where it lands: a shape the engine refuses, a reply that is not JSON, or aTerminalError, is a failed analysis with a Retry card; a refinement round is handed the round it refines (refining). The engine writes the files itself — no implementer — behind the same protected surface and diff gate (which also walls off every proposer pipeline’s own*.pipeline.ts, for the proposer agent too), then the same card, PR, check, watch, revert and lesson; every reader names the pipeline as the author. The proposer agent’s read-ban on the judge’s files does not bind a proposer pipeline (it is the brain’s own code); the write-ban does.omnislash doctor(Loop proposers) fails an id the brain does not serve.ProposerBrief/ProposerAnswercome from@omnislash-ai/pipeline-sdk loop_history— the loop’s ledger for brain code — the oploop.history, a read-only,safeEvaluationtool under capabilityloop:read(hitl: false), callable from a pipeline throughcallTooland live underloopLaunched: one row per cycle, newest first, filtered bytargetKind/targetId/feature/since, bounded bylimitand paged bycursor— the change (round, who wrote it —proposer, null for the proposer agent — hypothesis, files, PR), its merge (instant and gate) and revert, the check and watch verdict records, the verdict strength and the grade read off them, a person’s rejection with their note, the lesson. The columns are a documented contract (plain JSON; the table in self-evolving.md § Reading the history is the schema); instants are ISO stringsloop_get_proposal— one round’s whole proposal —loop.get_proposal(loop:read) returns every change a round proposes (proposer-agent text changes, a per-file diff for a proposer pipeline), its decision, PR and the apply’s report; the dashboard opens it as the proposal sheet at?proposal=<round>- Merge detection is event-first, poll-fallback — the
mergephase parks on a backoff ladder of the PR rung early by the PR’s own events: after its first read it opens a cycle resource watch on the PR’s key (github:pr:<repo>#<n>, whose delivery rings the cycle with no payload), and each segment reads the PR when the next event or the ladder’s rung arrives, whichever comes first. Where the deploy has a GitHub App, apull_requestcloseddelivery wakes the wait within seconds of the merge (after the watch’s short debounce), so the loop’s longest phase stops being a days-long poll. Thegh pr viewpoll (2 min → 6 h) stays unchanged and unrelaxed: it is the PAT lane’s ONLY mechanism, it covers a delivery GitHub never made (at most once) and an App whose owner has not subscribed topull_request, and its tick is also what re-checks the validation deadline. An event is a cue, never the verdict: it prompts an immediategh pr view, and only GitHub readingMERGED(orCLOSED) ends the wait — anyone who reaches the inbound sink can record an event about the PR; the merge instant is GitHub’smergedAt, never the moment the loop heard - Declarative per-gate autonomy —
loop.autonomydeclares who answers at each of the loop’s two human gates (plus the two “we don’t know” policies below):decide: human | auto(the Accept card),merge: human | auto(the PR;autoarms GitHub-native auto-merge once the pre-merge check has answered, so required checks stay a gate) plusmergeMethod: squash | rebase | merge. No direct merge exists:mergePrAutoanswersarmedorunavailablewith GitHub’s reason (the arm regex, or an HTTP 422 — the repo’s auto-merge setting off, a base branch that requires no status check so GitHub will not hold an arm, a forbidden method) and never falls back togh pr mergeitself;unavailabledemotes that round to the human merge gate with anauto-merge unavailable: <reason>notice, never a silent downgrade, never a failed apply over an open PR. The engine publishesomnislash/diff-gate: successon the pushed head before it opens the PR (a status GitHub refuses for permission fails the apply round at once, and the apply asks GitHub the same before the implementer’s turn — a rate-limit 403 is not a refusal) andomnislash/validationfrom the check, so a base-branch rule can require both;omnislash doctor(GitHub merge gate, Infrastructure) — asked only while some entity is in the loop, since the loop is the gate’s only client — reads the base branch’s rulesets + classic protection and warns whenomnislash/validationis not required, or when the repository has “Allow auto-merge” off (allow_auto_merge) while an entity declaresmerge: auto. Before those it asks GitHub whether the loop’s own credential may post a commit status at all — on every lane, by posting one on a commit that cannot exist (422 = granted, a 403 that is no rate limit = missing; nothing is written) — and a refused or undecided answer is reported ahead of the plan and required-context findings, naming the permission row (Commit statuses: Read and write) and the agent’s fix. Inside the deployment it judges the credential the loop actually posts under — an installation token where the App mints one for an https remote, otherwise theGH_TOKEN/GITHUB_TOKENgh reads, so an ssh brain remote (an~/.ssh/configalias included) is judged under that token; from a laptop it reads the rules with any token in the brain’s.envand leaves the deployment’s credential to the doctor inside it. A private repository on GitHub Free, which can carry no branch rule at all, is named as such; a configuration that leaves nothing to read (no remote, no credential, a token GitHub rejects or that cannot see the repository) is awarnnaming its own remedy. Engine default is human/human — existing brains change behavior by zero; the block merges WHOLESALE per tier (engine → manifest → frontmatter), so the tier that writes it owns every key. An apply PR abandoned at the validation deadline has its auto-merge disarmed before theunvalidatedsettle, so an unmeasured change can never land later off a stuck check going green- A disarm is heard, not assumed — a person disabling auto-merge on the PR reaches the loop as a
pull_request.auto_merge_disabledevent on the PR’s watch, and every read takesautoMergeRequeston every tick (null while armed = disarmed). The event is a cue too: it prompts an immediate read, and the arm is given up only when that read finds it gone — a forged disarm cannot switch off the head check while GitHub still holds the arm. Either way the wait continues under the human gate with anauto-merge-disarmednotice. A head that moved past the vouched commit — the checked one, or the one the apply opened the PR on where the cycle has no check; the arm is bound to it (--match-head-commit) either way — disarms the same way (itssynchronizeevent prompts that read at once). - Revert inherits
merge— no separate knob:merge: autoauto-merges revert PRs too, because autonomy without self-repair rots silently. The revert runs step by step (revert-create-branch/revert-push/revert-status-diff-gate+revert-status-validation/revert-create-pr, the PR looked up by head before it is created) with the sandbox removed infinally, so a re-run never double-opens; the two statuses aresuccesson the revert head, so a base branch that requires them takes the undo instead of holding it forever - Circuit breaker —
maxConsecutiveRegressions(default 2) consecutiveregressedverdicts demote both gates to human and notify, until someone re-arms (the entity’s dashboard page orloop_rearm); derived from the entity’s own verdict history (readBreaker→{ streak, blind }:validatedresets the streak, anunvalidatedneither counts nor resets it — EXCEPT one that shipped through an autonomous gate and could not be measured (merge_gate = 'auto'on a row withmerged_at— the effective gate, which nothing writes after the merge — so awatch.mode: nonechange that shipped on its own counts too), which isblindand trips the breaker on its own whatever the ceiling (unless the brain declaredautonomy.onUnmeasured: continue): a loop that would ship the next one the same way is the failure the breaker exists for; the durable re-arm isloop_cycles.regression_acked_aton the reviewed row, acked from the entity page), never a stored autonomy state; the breaker never blocks a revert.nulldisables the STREAK ceiling only —blindtrips under any ceiling.omnislash doctornames an unattended entity that took the opt-out under Loop autonomy, along with any auto/auto entity judged byqualityalone - What a person owes the loop —
listCyclesAwaitingPerson(rearm)(@omnislash-ai/infra): ONE statement, every gate kind —decide/retry(a pending HITL card of producerloop,data=loop.gate.v1:CycleGateDataSchemain@omnislash-ai/types/loop),grant(a pendinggrant_requestfrom a live cycle’s own agent, coalesced subscribers included),merge(applied+merge_gate = 'human', duemerge_wait_started_at + watch_deadline_hours),revert(a regressed change whose revert PR is human-gated —revert_merge_gate— and still awaited),rearm(the entities the caller found tripped under a declared auto gate, each resolved to the settle a re-arm acks) →{ cycleId, gate, round, since, deadlineAt, hitlId, prUrl }, oldest first. Therearminput is the seam: tripped-under-an-auto-gate needs the resolved autonomy block, which is brain config - The demotion announces itself once, at the settle that crosses —
loop:paused({ …lifecycle, streak, blind }) plus apausednotice fire at the settle where the streak reaches the ceiling or a blind change lands, and at no later regressed settle: the entity is already paused there, and an alarm repeated on every subsequent failure is one its reader learns to ignore. Thebreaker-tripped-r<round>line on the NEXT proposal’s card stays as the standing reminder that this round is human-answered. The pause lifts only on the owner’s ack — there is no half-open probe and no timer, deliberately - The implementer can never grant itself autonomy —
autonomylives insideloop:, which is already part of the protected measurement surface gated before every apply commit - No fixed human step under
decide: auto— the proposer and implementer of an autonomous round run withAuthContext.unattended:HitlGateway.requestrefuses their HITL requests before any row exists, so a grant request fails fast and amissing_grantstays a failure (carried to CLI harnesses over the MCP URL, inherited by spawned sub-agents). A broken analysis or apply settlesfailedat once — no Retry / Skip card, no lesson; the breaker does not count it,maxAnalysesPerDaybounds it.omnislash doctor(Loop autonomy) names eachhitl: truecapability the two loop roles reach once any entity declaresdecide: auto - Policies for “we don’t know” —
autonomy.onInconclusive: human | ship | close(defaulthuman) answers a pre-merge check that could not judge: a person merges, the declared gate stands (the loop postsomnislash/validation: successby policy; the cycle history shows “merged without proof by policy”), or the PR is closed and the cycle settlesunvalidated.autonomy.onUnmeasured: pause | continue(defaultpause) decides whether a blind auto-merge trips the breaker. Doctor namesmerge: auto+ship+continuetogether. Both keys are editable in the dashboard’s Policy view
- A disarm is heard, not assumed — a person disabling auto-merge on the PR reaches the loop as a
- Kill switch —
enabled: false—loop.enabled/evaluation.enabledAND across the tiers, sofalseinconstitution/MANIFEST.yamlswitches the whole brain off and no entity can override it (no brain function either:enabledis settled beforewindow/shouldOpen). It stops NEW cycles (a grade, thetrigger.everyclock, Analyze now, an idea) and closes every cycle in flight that has not merged. The cycle reads the resolved switch before every step it takes until the merge, and a switch written throughloop_set/ the dashboard rings every live cycle it reaches. When it readsfalse, the loop cancels the card, disarms auto-merge, closes the PR, and settles the cycleskippedwith one notice. The idea goes back to the queue and no lesson is written./evolutionreads such a cycle asclosed · loop off(closedBySwitch,@omnislash-ai/loop), its Change chapter saying the PR was closed by the switch and nothing shipped. A switch edited another way (a pushed MANIFEST) reaches a wait at its next look (at most 6h). A merged change finishes its watch and revert.evaluation.enabled: falsealso stops the grading an in-flight check or watch waits on. Documented with the in-flight moves inself-evolving.md§ Kill switch - Supervising the loop — re-arm, hold, release, dismiss — four
Evaluation-category tools that are the entity page’s buttons as code, each through one engine entry point (rearmBreaker/holdLoop/releaseCyclein@omnislash-ai/loop,ackLoopFailurein its control core), behind two capabilities: the hold isloop:hold(moderate,hitl: false— it only takes autonomy away, so an agent that sees trouble stops the loop without waiting), while re-arm, release and dismissal hand oversight back and stayloop:supervise(moderate,hitl: trueby default)loop_rearm— the same tripped-breaker guard as the dashboard’s Re-arm; the ack records its Actor inloop_cycles.regression_acked_by, shown in the cycle history and the Supervision listloop_hold/loop_release—omnislash.loop_holdsrows (target: an entity, or a lock group declared underloop.groups; who, why, when; released holds kept as history). A standing hold refuses every producer — tick, grade, “Analyze now”, a queued idea — and nothing else: gates unchanged, no card, a cycle in flight finishes. A group hold stands on every member. Hold / Release buttons sit next to Re-arm on the entity page, with the hold banner naming who held it and whyloop_ack_failure— dismisses one terminal failure from Loop failures and records who inloop_cycles.failure_acked_by; attention bookkeeping, not a re-arm
- The loop’s reads are ops — every read the Self-evolving page makes is one
loop:readop an agent calls as a tool:loop_status(the fleet with each entity’s feature, preset, cadence, lock groups, own record, grading facts, the window its next analysis reads and the join plan; withjoinOrderits place in the join order — graded first, then the most hand-tuned,joinRank— and people’s recent edits to its files,handEdits, from the brain’s git history; the features the fleet groups by),loop_get(one entity whole: status, supervision record, lock groups, live cycle and its agents, the way to a first cycle, ratings, ideas, lessons, and its newest cycles told whole with their grade),loop_trace(entities’ metrics over their graded runs — the loop’s recent window, or one entity’s whole history: values with the scale a person answers on, bad runs, brain versions, the level line cut at every shipped change, a marker per cycle with its grade; several entities answer their objective each),loop_summary(the window’s settled cycles, win rate, cycles per week by grade, the kept cycles’ uplift, gates owed — on the stats contract) andloop_list_agents(the judge, proposer and implementer as the brain configures them, with model ladder, grant and what it compiles to, where each runs, doctor verdicts and consequences; the proposer pipelines). The dashboard composes its pages from them and holds no loop composition of its own - Agents run the loop through validated tools —
loop_status/loop_policy(loop:read),loop_join/loop_set(loop:configure,hitl: false),loop_widen(loop:autonomy,dangerous,hitl: true) andloop_analyze(loop:analyze) are theloop.*ops the dashboard’s loop pages run too, so an agent’s click is the dashboard’s click — one definition, one action-log row per write against the entity it steers.loop_join/loop_set/loop_widentakedryRunand answer each file’s diff (files);loop_policyalso answers the editors’ views (sections), the lock groups in scope with holders and members, the proposer’s added lines, one entity’s judge calibration and the loop’s code- Configure and autonomy are split —
loop_setrefuses a write that widens oversight (widens-oversight): autonomy, the change perimeter (editablePaths,protectedPaths,maxChangedLines) or the brain-wide loop switch, judged on resolved values for every entity the write reaches, before and after — and one that takes an entity out of a lock group a person holds (lifts-hold). Narrowing goes through.loop_widenmakes the widening write once a person approves its grant, and answers eachwidened[]subject - One writer at a time, from any process — every loop-settings write (dashboard editors and tools alike) takes one cross-process lock and commits by content compare-and-swap; a file that kept changing or a lock held too long answers
contended— “try again”, nothing written - Status names the next step —
loop_statuslists what each entity waits on (needsYou[]) withnext: who acts, and through which card, PR or tool; the loop’s cards take no agent answer or cancel, and its PRs are a person’s to merge (a rule in theself-evolvingskill — the engine cannot tell an agent’s merge apart)
- Configure and autonomy are split —
- Lock groups — one variable at a time across entities —
loop.groups(MANIFEST only:<id>: { lock: true, repair: bypass | wait }, engine{}) declares entities that share an outcome;loop.groupjoins them at any tier (nearest wins the list,[]leaves; doctor Loop groups fails an undeclared name). While a member has a change past its Accept and unsettled, or a regressed one whose revert is awaited, no other member opens a cycle (the producer refuses early, naming the holding change) or is accepted (the claim takespg_advisory_xact_lockper sorted group, checks for a holder and takes the entity in one transaction; the membership is stamped on the row asloop_cycles.lock_groups). Arepairmember passes arepair: bypassgroup. A member’s watch that saw another member ship or revert after its baseline closes confounded (unvalidated,loop_cycles.confounded_bynames the change): the comparator’s record is kept but never acted on — no credit, no revert (watch.onInconclusiveincluded) — and the breaker counts it blind. Every watch, grouped or not, also closes confounded on an edit of the entity’s files (its defining files and the files its change landed) that landed on the brain’s branch after the baseline (git reachability, not dates) and is neither part of the cycle’s own merge nor a pure edit of the entity’sloop:settings — a person’s PR, an agent’s commit, another entity’s change to a shared file — stored asloop_cycles.confounded_by_edit. A member’s next window starts after the group’s last merge or revert (since, andctx.groupChangedAtforrepair’s last-n count). An Accept that loses the group race settlesskippednaming the holder (underdecide: autoat once, no card) and hands its idea back to the queue. Edit boundary: no change writes another loop entity’s definition files; what a member lists in itseditablePathsis denied to every other entity’s change, unless that entity shares a group with it (an entity the lock does not hold would otherwise move a member’s numbers under a watch that settles clean; a fellow member is kept apart by the lock itself); and a member’s existing shared files (*.agent.md,skills/,constitution/Planner/) open only through itseditablePaths. The entity page shows each group’s holder, members and waiting ideas; the fleet read carries the lock status - Grant-gated auto-accept — a standing
loop:auto_acceptgrant on a scenario (admin-issued, scenario-scoped,maxCalls-bounded, TTL’d, revocable) lets the analyze step apply its own suggestions instead of opening the card, burning one grant use and posting an informational notice — the temporary, scoped delegation layered over ahumanconfig, whereautonomy.decide: autois standing policy- Delegation, not risk scoring — “low-risk” is never computed by the engine; the capability sits in
ADMIN_ONLY_CAPABILITIES, so no approval card can mint it and the planner’s vocabulary excludes it - Remaining gates stay — the apply PR is still merged through its own
mergegate and the pre-merge check, the watch and auto-revert still settle the verdict; no grant (or any lookup failure) falls back to the card, a refined round or an empty suggestion set is never auto-accepted, and a tripped circuit breaker vetoes the grant path exactly as it vetoes the config path
- Delegation, not risk scoring — “low-risk” is never computed by the engine; the capability sits in
- Loop events — the whole lifecycle, not just the analysis —
loop:analysis.started/.suggested/.failed/.skippedfrom the analysis service, thenloop:accepted/skipped/failed/applied/validating/validated/regressed/unvalidated/reverted/pausedfrom the loop’s own transitions, each carrying the same envelope plus what its transition genuinely knows (the PR url, the challenger tally, the comparator’s reason, the revert url, the breaker’sstreak/blind) —accepted,validatingandrevertedcarry anautoflag telling the autonomous gate (config or grant, armed auto-merge) apart from a human, so a surface can tell human-gated from autonomous flow.acceptedandskippedcarry anactorBESIDE that flag, because the two answer different questions:autosays whether a person was asked,actorsays who answered — the tapping speaker on the human path, and the cycle itself for every answer nobody was asked for (an admin-only grant carries no issuer to name, so the two autonomous branches are not separated here). Pure pub/sub — the row is the system of record and nothing work-triggering may hang off them — so they drive cards, the activity feed and dashboard invalidation, and never a side effect- The half that used to be silent is what makes the loop watchable — before them the loop’s page (then
/self-evolving) was the one page in the dashboard that polled, because nothing past analysis announced itself; every Self-Evolving surface now invalidates off these and off*:eval.completed, with no bespoke clock
- The half that used to be silent is what makes the loop watchable — before them the loop’s page (then
- Runtime health of the loop itself — the dashboard’s
/evolutionloop checks carry two that read the database rather than the brain’s files, because the surfaces above answer what the loop decided and none of them answers is the loop alive. Two checks, not one: a stuck cycle and an unread failure are independent facts, and a single check can only report the first that matches — folded together, one stuck row would silently delete the whole failure surface- Agents read both through
loop_status: the fleet’slifecycle(live and stuck cycles) and an entity’sfailureRows - Loop lifecycle — stuck cycles, read-only — a pure classifier (
findStuckCycles) over the machine-live rows (listMachineCycles) reports two shapes, read off the row alone — no Restate question, nothing that degrades when its admin is out of reach: failing (a segment leased the row and never parked or moved it:leased_atolder than one segment’s lease plus a 30-minute slack — the phase keeps throwing on every retry) and unrung (no lease, andwake_atpassed more than 30 minutes ago — nothing is ringing cycles: the scheduler’s wake reconciler or pipelines is down).warn, notfail: neither is lost — a failing cycle is ended by the engine atleased_at + 24 h(the hint names the instant), an unrung one moves the moment rings arrive — but each holds its entity’s claim until then. It never writes: thecyclemachine is the sole writer of its row, and the wake reconciler (rings, never writes) is the only thing that acts on an overdue one.loop_status, the brief and the cycle’s story show the same row as In<phase>since … · next look in … - Loop failures — terminal failures, acknowledgeable — a
failedsettle, or aregressedone that shipped (merged_at IS NOT NULL— GitHub’s merge instant, recorded under every watch mode) and was never undone (no revert PR, or oneclosed/unmerged/ past its deadline with no outcome — a revert still inside its wait is not listed), becomes a health row; aregressedthe pre-merge check settled never shipped and owed no revert, so it is not one that persists until a human dismisses it from the page’s “need you” list, or an agent does withloop_ack_failure(loop:supervise) (loop_cycles.failure_acked_at, mirroringregression_acked_at, with who infailure_acked_by; the ack is guarded by the same predicate the list selects on, so a live row can never be pre-emptively acked into silence). The discriminator is the revert’s own record (revert_pr_url+revert_outcome), not a non-nullerror— a message survives a retried round, and it is absent entirely when the invocation dies between the settle and the revert. The revert failure is now RECORDED on the row rather than existing only as a chat notice that scrolls away, and botherrorand the ack are reset wherever a proposal re-enters the lifecycle, so an ack is per attempt rather than per row. The ack is not the autonomy re-arm: it changes nothing the loop reads, and the two write different columns so neither implies the other
- Agents read both through
- Per-cycle spend —
loop_cycles.agent_idslinks every proposer and implementer back to the cycle that dispatched it, across every one of its rounds, appended inside the SAME journaled step that creates the agent (idempotent in SQL, since actx.runis at-least-once), and the retention reaper exemptssource = 'loop'so the rows it names — and theturn.completedevents that cascade off them — outlive a validation window that is 168h by default (the spend ledger,usage_records, outlives them regardless). That is what turns “the loop cost $12 this month” into “this cycle cost $0.40, across two refines and a retry”, rendered in the cycle’s facts on/evolutionand priced from the spend ledger (usage_records) by those agent ids. Challenger executions are deliberately excluded and the returned shape says so: their run ids reach agents through a different table per entity kind, and for an OBSERVED entity they are the entity’s own traffic, which would have cost the same with no cycle in flight - Eval trend on the card — the suggestion card shows the target’s recent eval trend (pass rate + average score) for context, and — when the entity declares metrics — what the change is being optimized toward and what is guarding it, so a reviewer sees the terms before merging
- The proposer is given a goal, not an adjective — a metric-driven analysis is handed
metrics.json(objective with its baseline and target, guards with their tolerances, the entity’s recent trend) plus an## Optimization Targetprompt section that names the mechanism out loud: guards are measured on the same challengers, so a change that buys its objective by doing less of the work will be reverted automatically
Scheduler & Heartbeats
-
Scheduler app — two orthogonal reconcilers with separate flags —
BlueprintReconciler(reconcile.enabled) keeps blueprints in sync with scenario sources, andScheduleSynchronizer(scheduler.enabled) converges BullMQ repeatables with the manifest; the synchronizer always runs so a disabled scheduler reaps stale repeatables instead of leaving zombies.scheduler.enabledis the brake over every clock — off, both the scenario crons and the heartbeat ticks are unscheduled (and a tick already in flight is dropped by its worker); hooks and manual runs are not on a clock and keep working- Every MANIFEST toggle is live —
scheduler.enabled,reconcile.enabledandreconcile.scheduleare re-read on every pass (the periodic reconcile checks its cadence once a minute), so a dashboard or agent edit takes effect without a restart;reconcile.scheduletakes onlydisabled/every N minutes/every N hours, refused at write otherwise - One timezone — every schedule resolves in the brain’s
locale.timezone(omnislash.config.ts, defaultUTC); MANIFEST.yaml has notimezonekey - Startup ordering — initial convergence (blueprints → schedules → heartbeats) completes before the health server binds, so Docker
service_healthygates workers on a first reap+sync - Content watcher in every mode, production included — a
brain-synctick landing a new scenario/heartbeat converges immediately instead of at the next periodic pass - Reconcile on request —
schedule_reconcile(and the dashboard’s Reconcile button, amanifest_updateofscheduler/reconcile— the MANIFEST brake toggles) queues one deduplicatedreconcilejob onscheduler-command; the scheduler’s consumer runs the forced repair → reconcile → sync pass. Durable: a request made while the scheduler is down runs when it comes back; a burst coalesces into the one waiting job, and a request made while a pass runs queues behind it, so the clocks always follow the latest change manifest_update(config:write) — editsconstitution/MANIFEST.yamlby a nested partial: each leaf is set in place as text, so every comment and unnamed key survives; validated as the loader reads the file, D30-shaped;unset(each key as its path,[["scheduler", "enabled"]]) removes keys so the engine’s default answers again, under the same refusals. One touchingscheduler,reconcileorbrief(itsscheduleis a clock the scheduler registers) queues the reconcile above (consequences.scheduleReconcile). Refused for a section another tool owns under its own gate:loop/evaluation/chatareloop_set’s (its widening check sends autonomy, perimeter and the brain-wide switch toloop_widen),budgetisbudget_update’s (budget_widento raise or remove a limit). The dashboard’s brake toggles and Memory Settings drawer call the same opmanifest_get(config:read, since 1.23.0) — MANIFEST.yaml read back: every section (or one,section) as the engine uses it (values), only what the file itself sets (declared— a key absent there is the engine’s default) and theshamanifest_updatetakes. The dashboard’s Brain-wide card (/features) reads it for the health windows and the hook defaultsmanifest_set_retention(governance:write— restricted, approval by default) — setsaudit.retentionDays(the action log) orusage.retentionDays(the spend ledger),0= forever; a shorter window deletes the older rows on the next reaper pass, so it is notmanifest_update’s. D30-shaped- Also hosted here —
QueueStatsWriter(depths of the queues a schedule feeds — planning, workflow and pipeline crons, heartbeat,schedule_create_prompt, HITL drain — to Redis for the dashboard), health server +/metrics,ServiceRegistryheartbeats, thehitl-reconcile(60 s) andconnection-refresh(600 s) job schedulers, heartbeat retention sweep (24 h, 30-day cutoff), and stale heartbeat-run pruning - Modes —
omnislash scheduler(long-running) andomnislash scheduler --once(single convergence then stop)
-
Auto-planning from scenario changes — the reconcile tick hashes manifest + scenarios +
.blueprint.ymlfiles, skips unchanged and recently-modified sources, and enqueuesauto-plan-{scenarioId}-{contentHash}planning jobs (reaping stale same-id jobs first); planning itself is the workflow slice’s.- Hash advance only on a complete pass — a pass that deferred or failed to enqueue a scenario leaves
lastHashunset so the next tick retries - Two-layer dedup — the BullMQ
jobIdplus the planning worker’s probe of thescenarioHashstamped into.blueprint.yml - Concurrency — a Redis distributed lock (
omnislash:blueprint-reconcile-lock) across processes plus in-processSingleFlightcoalescing scheduler:scenario.skipped/scheduler:reconcile.completed/scheduler:reconcile.failedevents, with reconcile state persisted atomnislash:reconcile-last(24 h TTL)
- Hash advance only on a complete pass — a pass that deferred or failed to enqueue a scenario leaves
-
Desired-state cron synchronisation —
syncRepeatableJobs()diffs desired vs. current BullMQ job schedulers per prefix (cron-*for workflows,pipeline-cron-*for pipelines), upserting changed entries, removing absent ones and reaping hex-keyed leftovers from the legacyqueue.add({repeat})path.- Unreadable files are held, not reaped —
DesiredState.unreadableis a required field, and onlycause: 'unparsable'qualifies; a schema-rejected file (schedule:deleted,enabled: "false") is positive knowledge and is reaped - Held jobs keep their previous schedule and are reported as
heldinSyncResult; a skipped scenario is held on both cron queues because its engine kind is unknowable - Self-healing trigger workers — a workflow cron, pipeline cron or heartbeat tick that fires while
scheduler.enabled=falseremoves its own scheduler and throwsUnrecoverableError(a manual heartbeat run is let through)
- Unreadable files are held, not reaped —
-
EntityRepairer— auto-repair malformed entity frontmatter — runs ahead of every reconcile (start,sync(), each content tick), rewrites repairable frontmatter on disk viaatomicWriteFileso brain-sync commits a reviewable diff, and alerts the status channel on both outcomes (repairs announced; unrepairable files deduped per path in Redis for 6 h with the claim released on enqueue failure). -
Schedule grammar —
resolveSimpleScheduleis a synchronous regex parser (every N minutes,every day at 11am,every weekday at 9:30,every monday at 9:00, raw 5-field numeric cron,disabled), withintervalToCronfolding intervals into cron when they map cleanly. The scheduler reads a frontmatterschedule:through this grammar ALONE, so a string outside it never fires;scenario_update/heartbeat_updatetherefore refuse a schedule that would never fire (scheduleFault: outside the grammar, or a cron BullMQ cannot step — an out-of-range field, an impossible date, an unknown timezone). Doctor Scheduled work fails on such a schedule in any scenario or heartbeat file. -
Pipeline cron scheduling — scenarios with
execution.engine: pipeline(or an auto-detected sibling.pipeline.ts) bypass planning entirely: the schedule resolves from frontmatter on every sync into apipeline-cron-{scenarioId}scheduler whose worker POSTs the Restate ingress with an idempotency key, emittingscheduler:pipeline.triggered; the Restate service name must equal the scenario id (validated at startup), andpipeline.targetKeysupports keyed triggers. -
Heartbeats — periodic agent prompts without workflow overhead — a
*.heartbeat.mdis reconciled into a BullMQ repeatable every 60 s (and on content change; none whilescheduler.enabledis off), and each tick mints a freshagentsrow grouped byhb:<heartbeatId>rather than a separate run-history table.agent:frontmatter is anAgentSpec—role/modelRef/repo/systemPrompt/runIn, consumed at row creation;runIn: cloneis the only durable way to give a heartbeat a pushable checkout so it can open a PR- A clone says where its content stops — a
runIn: clone/sandboxcheckout is a snapshot, and from the inside a stale one is indistinguishable from a fresh one: files are whole, the tree is clean, andgit loganswers about a window it only half covers. The## Environmentsection states where the checkout’s INHERITED content ends — the commit it was cloned from, which stays put as the agent commits on top — so the agent can compare it against the turn’s clock. A fact, not a verdict, and no remedy attached: “stale” would need a threshold, and how far behind is too far is the brain’s question, not the engine’s - Standing grants via
auth.heartbeatId— the(heartbeat, <slug>)scope is the only durable name available at approve time, so one approve covers 30 days of ticks;heartbeatIdridesMCP_AUTH_SCALAR_PARAMSso a CLI-harness tick keeps its grant at the tool gate, and renaming the file mints a new scope - Declarative pre-authorization — a
grants:block materialized byissueDeclaredGrantsbeforeagent.call, because an approval card blocks the whole tick (the previous lever was a brain-wide"hitl": false) maxStalledCount: 0—await agent.callholds the job through HITL pauses, so a stalled job fails immediately rather than re-running the prompt twice and duplicating the morning message- Audit in
meta.heartbeat—heartbeatId,jobId,triggerSource,createdByat create;durationMs,exitCode,error,errorStack,errorCategory,provider,hasToolUseandtoolFailuresmerged atomically afterwards, on the failure path too toolFailuresis the broken-delivery signal — a tick whose notify tool 400s still completes, and without this field the failure is invisible outside logs; the dashboard ranks such a run as warning, not healthy- A gate decides per tick —
<name>.heartbeat.tsbeside<name>.heartbeat.md— the same gate a hook has (§ Hooks › Gates):export default defineHeartbeatGate(async (tick, ctx) => …)from@omnislash-ai/config/public,tick={ scheduledAt, trigger: 'cron' | 'manual' },ctx= the heartbeat (id,name,feature),reach,signal,log,getServiceToken(providerId)(a declared OAuth provider’s token) andfence(text)(fetched text — a PR title — goes intovarsthrough it). Asked after the brake and the switch, before the tick’s agent, grants or turn exist — a manual run too.false→ the tick endsskipped(reasongate) with no agent row (noheartbeat:completed, no grade, no challenger; health still counts the fire as served);{ vars }fill the body’s{{name}};{ reach }/{ entity }shape its reach; a throw, a timeout (heartbeats.gate.timeoutMs, the heartbeat’sgate: { timeoutMs }, 30 s by default) or no verdict fails the tick (gate_error, no agent; tried once per tick, so health counts it in the failure streak rather than reading Failing at once as a retried hook gate does). For a heartbeat with a cheap stop condition (“only while no PR is open”) it is the difference between an idle tick and a paid turn. DoctorHeartbeat gates; Atlas shows the gate on the heartbeat’s card; the loop’s implementer may not edit it - Manual trigger —
heartbeat_start(run:execute) queues a tick out of schedule on the same worker path withtriggerSource='manual'and answers its run id; refused for a heartbeat that does not load or is switched off (itself or by its feature) — the scheduler brake stops clocks only, so a braked heartbeat still runs - Heartbeat ops —
heartbeat_get(feature:read: the switch own and effective with what holds it, the schedule in words and the next tick, the agent, its gate file (gate: { path, sha }, null when every tick runs), the frontmatter as written, the body and its{{placeholders}}, the file’spath/sha; a file that does not load answersheartbeat: null+broken),heartbeat_read_file(feature:read, since 1.23.0: the whole file as written with its sha — what a raw edit starts from),heartbeat_update(feature:write, typed keysname/description/enabled/schedule/memoryFlush/agent/content; a schedule that would never fire is refused),heartbeat_write_file(the raw file, validated by the heartbeat loader),heartbeat_widen(feature:widen, HITL — the write that adds to itsgrants:). Every write is dry-run → commit on the returned sha (the one checked writer); one that moves the schedule or the switch queues the schedule reconcile after its commit (consequences.scheduleReconcile);budget/evaluationare refused (their own tools) - Eval + loop subject —
evaluation:/loop:frontmatter, sampling decided at the producer (decideEvaluation) with a mandatory ceiling, a grader that judges tool calls rather than status, andobserve-mode validation that watches the entity’s own next ticks instead of launching challengers - Events —
heartbeat:completed(a tick’s dispatched turn ended),heartbeat:tick.undispatched(a tick ended before any turn:skippedwith itsreason—brake(the scheduler brake),disabled(the heartbeat switched off),gate(its gate said no) — orfailed— its definition missing or unparsable (errorClass), its gate failed (gate_error), a throw before the dispatch; asystem-category event) plus theheartbeat:eval.*family (started,completed,failed,skipped,parse_failed)
-
Schedule ops and tools — the
schedule.*ops (@omnislash-ai/engine-api), each an agent tool and a dashboard procedure:schedule_overview(schedule:read) — everything that runs on a clock or waits on one: the scheduler brake + the brain’s timezone; every scheduled scenario (workflow and pipeline) and every heartbeat, switched-off ones included, each withoffReason(entity/feature/scheduler/schedule—disabled, unreadable, or a cron with no valid time — / none), its next fire and last run (the newest execution, in the run vocabulary); hooks with the event each listens on; pending one-shot scheduled prompts and live resource watches, each with thefeatureof its agent (agent → role → the feature shipping the role; a system role isengine); the engine passes (passes, featureengine: the daily brief, memory consolidation and flush, the self-evolving analysis sweep, the retention sweeps, the blueprint reconcile — each with its clock in words, its next fire where a registered clock fires it, its last run and outcome where the engine records one, andrunNow: 'brief.send'on the brief; no pass writes anexecutionsrow).featurenarrows everything to one feature (engine= the passes and the system agents’ prompts and watches). A workflow scenario carries its.blueprint.ymlmetadata — or, for a plan file that exists but cannot be used,blueprint: { state: 'invalid', file, reason }, while every other row reads normally- The next fire is BullMQ’s — for a clock the scheduler registered (
cron-*,pipeline-cron-*,hb-*job schedulers)nextFireAtis the job scheduler’s ownnext(nextFireSource: 'bullmq'); a clock not registered is projected from its file ('projected') and readsregistered: false— firing per its file but not registered is a schedule declared but not registered, which a reconcile fixes; a switched-off row (offReasonset) never shows a next fire, and one whose clock is still registered counts insummary.registeredOff(its ticks are dropped at fire time until a reconcile removes it) schedule_forecast(schedule:read) — what fires in the next6h/24h/48h/7d(ahead, since 1.24.0 — waswindow), soonest first: every firing scenario cron and heartbeat (its registered clock’s queued next fire, then the ticks its schedule projects —source: 'queued' | 'computed',approximatefor an interval no cron expresses; a queued fire already past — the scheduler down or behind — is oneoverdue: truerow, never every tick it missed), every pending scheduled prompt whose timer falls in the window (prompt:sp_…) and the engine passes on a registered clock (feature:engine+pass: the brief, memory consolidation — the in-process interval passes keep their phase inside their process and are left out); nothing switched off or braked; a clock lists at most 300 fires, and one past that (firing every minute over7d) is named inclippedwith its total.featurenarrows it like the overviewschedule_status(schedule:read) — the scheduler service: brakes and timezone, the last blueprint reconcile (when, hash, scenarios, auto-plan jobs, archived orphans), the reconcile lock, a requested reconcile still waiting or running, the depth of the queues a schedule feedsschedule_reconcile(schedule:write) — converge now (see Reconcile on request above); answersjoined: truewhen a request was already waitingscenario_update(feature:write) — a scenario’sschedule/enabledare scenario keys: a typedschedulethe scheduler would never fire is refused (scheduleFault), and the commit queues the schedule reconcile (Scenario ops, above)
-
One-shot scheduled prompts (
schedule_create_prompt) — an agent schedules a future prompt to itself (“remind me tomorrow at 7:30”, snooze, nag re-ping), delivered throughagent.sendand replaying the scheduling turn’s auth at fire time.- A row and a timer — each prompt is a
scheduled_promptsrow (sp_…:scheduled→firedonce delivered — the row’sfiredand the agent’s inbox entry commit in one transaction, so a retry neither re-delivers nor loses it /cancelled/failed; a delivery that failed before that commit staysscheduled, so a replace or cancel still stops its retry) next to the BullMQ delayed job that fires it; the same id replaces the pending prompt (its row is cancelled),action=cancelaborts it;schedule_cancel_prompt(schedule:write, recorded in the action log) cancels a pending one by itssp_…id — the dashboard’s Cancel; an agent cancels only its own, a person, the dashboard or an MCP session any. What is coming up is answered from the table (schedule_overview), not from Redis - A replaced or cancelled prompt never fires — the worker reads the row before delivering, so a job already in flight for a cancelled row delivers nothing; the row reads
failedwhen delivery gives up
- A row and a timer — each prompt is a
-
Dispatch provenance in chat (
TurnDispatch) — a turn nobody typed (a firedschedule_create_prompt, a heartbeat’snotification_send) is headed with what woke the agent:⏰ **Scheduled** · \wake-nag` —/🫀 Heartbeat · `daily-challenge“- Derived, never stored twice —
turnDispatchOfreads the idsAuthContextalready carries,scheduledPromptId(stamped byschedulePromptWorkeron the fired turn only) andheartbeatId; the schedule wins when a beat scheduled the prompt - One rule on both row-reading paths — the origin chat’s
settleChatDeliveryand theCrossSurfaceNotifiermirror sharedispatchProvenance.ts: the turn’suser_messagerow is a note the agent wrote itself, so it heads the first message instead of posting as a💬human quote - Two carriers —
turn.completedfor the answer path, theui-notificationsjob for the notify path, which stamps provenance only when the destination is not the calling turn’s own surface - Rendering —
renderDispatchHeaderin the body’s own dialect (markdown / HTML / plain), on the turn’s first message only, and on the ⚠️ bubble of the last failed turn
- Derived, never stored twice —
-
Engine notices — one message per thing, gated by your level (
notify:,notice_list) — everything the engine posts unasked (status cards of runs nobody in that chat started, eval cards, hook failures, model fallbacks and rejected credentials, expired connections, recovery failures, sync conflicts, entity repairs, the loop’s notices, Grafana alerts, the restart DM) names what it reports on and how much it matters, and passes one gate (notifications)- Your level —
notify: { level, remindAfterHours }: enginenews/ 24 h → MANIFESTnotify:→ a scenario’s, heartbeat’s or hook’s ownnotify:(manifest_update,*_update);allshows passes and clean runs too,failuresonly what broke,nonenothing - Repeats fold — the same thing in the same state edits its earlier message (
↻ ×N since …, no push) untilremindAfterHours; a recovery marks it✅ Resolved; a new state is a new message - What is still open —
notice_list(notice.list,runtime:read), andbrief_digest’sopenNotices - Agents too —
notification_send({ subject })replaces the agent’s earlier message on that subject in place; the brain’s level never gates an agent’s own words; it is never an open notice — nothing resolves it, sonotice_listand the brief leave it out
- Your level —
-
Daily brief (
brief.schedule,brief_digest,brief_send,brief_status) — once a day the chief writes the brief into its DM with the primary owner: what ran, what failed and why, what waits on a person, what it cost. It arrives as a message of that chat (Telegram and every surface bound to it), headed📰 **Daily brief** · \2026-10-10“, and follow-ups are ordinary replies- The clock — MANIFEST
brief.schedule:HH:MMin the brain’slocale.timezone(engine default07:45) ordisabled. One keyed BullMQ job scheduler,brief-tick, registered by the scheduler and re-converged on every content change; the worker consumes it. Not gated byscheduler.enabled— that brakes the brain’s own crons and heartbeats, not the engine’s passes - What the chief is told — a note naming the day and the digest window, and the instructions: the engine’s default, or MANIFEST
brief.instructions(absent ornull= the default — Home’s brief settings write it); the chief’s persona sets the voice. The note carries no facts: the chief reads them withbrief_digest(brief.digest,runtime:read) — executions per kind and status, the newest failures and self-evolving cycle steps by name (each naming its cycle and step;cycles.totalcounts distinct cycles), requests waiting on a person, the engine notices still open (openNotices), over{ from, to }(default the last 24 hours). Spend is not in the digest: it stays behindusage:read, and the note points the chief atusage_summaryover the same window, spelled as wall clockfrom/toin the brain’s timezone — a chief deniedusage:readwrites the brief without it - One brief a day — the note is a
kind: 'brief'inbox entry, served alone as one turn under its own auth (AuthContext.brief, which also makes the turn’sdispatch.kind: 'brief'). The tick skips a brief in flight, a day whose brief already ended (since local midnight), and a quiet stretch (no execution started and no turn priced since the last brief, at most 24 hours back). The check and the entry are one transaction under one cross-process lock, so a retried or twice-delivered tick never sends twice. The brief’s turn survives compaction of the chat’s history (/compactor auto-summarize move it out of the live history instead of deleting it), so a compacted chat still counts today’s brief as sent - Send now —
brief_send(brief.send,agent:execute; the dashboard’s “Send now”) writes one now, skipping the quiet and already-sent checks; while a brief is queued or being written it sends nothing (status: 'in_flight'). The tick runs as the primary owner (the DM’s principal);brief_sendas its caller - Did it go out? —
brief_status(brief.status,runtime:read; Home’s Daily brief panel — the instructions it writes from aremanifest_get’sbrief.instructions): today’s brief as written (since local midnight), the brief being written now, the clock, andquietSinceonce today’s brief time passed on a quiet day (“Quiet — nothing ran since…”) - No chief DM, no brief — a brain with no Telegram bot running the chief’s role, or no primary owner, or whose bot’s
policy.chat()serves no role in that DM, skips every tick (apolicy.chat()naming another role there is followed, as the bot follows it); the doctor’sDaily briefcheck says why (brief_sendrefuses with the same reason)
- The clock — MANIFEST
-
Proactive grant provisioning for scheduled work — a person, or an agent with the person’s yes, can issue a grant ahead of the run that needs it (
grant_create, for the scenario, heartbeat, hook or agent;grant_list({ capability })says what already holds it), so a scheduled run does not stall on an approval card at an inconvenient hour; a brain that wants it standing declares it in the entity’sgrants:block instead; theschedule_cronHITL producer cell exists for the same purpose but has no engine-side producer call site today. -
Time-bomb register — values that are correct today but pinned to someone else’s release schedule (currently the assumed Codex default model used for cost attribution) are catalogued in the engine next to their owning code, each with an explicit “update when” trigger and update procedure.
Hooks
-
Event-driven hooks —
*.hook.mdanywhere inside a feature directory — a hook says “on this event, matching this filter, run this prompt as this agent”; frontmatter carriesevent, optionalfilter,agent(bare role string or fullAgentSpec),enabledand an optionalgrants:block, with the file body as the prompt template.- Template rendering —
fillTemplateinterpolates the whole event payload pluseventNameandhookNameinto the prompt; a payload field the event’s schema marks as outside content (themtproto:message.*text, sender name, sender record, file name, contact, buttons) (and thegithub:*titles, bodies, comments, branch/label names and check output) renders fenced bywrapExternalContent, never raw — an object field fenced whole, as the JSON the template prints - Filter matching —
matchesFilterover event-body keys with array-contains semantics (atags: 'outreach'filter matches a['outreach','priority']array) and any-of for a list against a scalar (authorAssociation: [OWNER, MEMBER]) - Inline hooks in scenarios — a
*.scenario.mdcan declarehooks: [...]in frontmatter (InlineHookFrontmatterSchema= the hook frontmatter plus a requiredid:slug (no.) — the hook’s id is<scenarioId>.<id>, unique within the scenario, and doctorHook idsfails on a standalone hook of the same id — and an explicitpromptTemplate, minusgate:,chatLaunches:andagent.scope: chat, which it refuses); an inline hook hears only its own run’s events, so doctorHook eventswarns one on an event that names no run (mtproto:*, for one)
- Template rendering —
-
Gates — the code half of a hook or a heartbeat — ONE primitive with two kinds (
@omnislash-ai/config/gate): a<name>.hook.tsbeside a hook decides per fire, a<name>.heartbeat.tsbeside a heartbeat per tick (§ Scheduler & Heartbeats); both answer the same verdict under the same timeout seam (hooks.gate/heartbeats.gatein MANIFEST,gate:in the entity’s frontmatter), never fail open, and a no ends the runskippedwith run status reasongate— told apart from any other skip inrun_list/run_get. A hook’s gate is the code half of its condition, for any event:export default defineHookGate<'<event>'>(async (event, ctx) => …)from@omnislash-ai/config/public, witheventtyped by that event’s schema andctxcarrying the hook (id,name,feature,event), anAbortSignal, a logger,fence(text)— which fences counterpart text the gate puts intovars(a var renders exactly as returned) — andgetServiceToken(providerId), a brain-declared OAuth provider’s live token (a heartbeat’s gate has the samefenceandgetServiceToken).- Verdict —
falseends the fire with no agent (hook:skipped, nothook:failed; the runskipped·gate);trueruns the hook as declared;{ vars }runs it with extra{{…}}template variables over the event’s own fields;{ reach }replaces the contexts the fire reaches (the hook’sreach:, else its directory and above — handed to the gate asctx.reach), and{ entity }keys the reach’s one keyed context (alone or besidevars/reach; none or several fails the fire) (contexts.md § 2.3). The role stays the frontmatter’s — a gate cannot pick one (a{ agent }answer fails the fire) - Runs in the keyed
hook-triggerjob — afterfiltermatched, before grants or the agent; durable and retried with the job, so a gate may run more than once per event (keep side effects idempotent).omnislash hooks triggergoes through it too - Fails loudly — a gate that throws, answers anything but a verdict, cannot be imported or has no default-exported function fails the fire (
hook:failed, no agent); the hook never runs ungated - Timeout —
timeoutMs30 s by default,hooks.gate.timeoutMsinconstitution/MANIFEST.yamlbrain-wide,gate: { timeoutMs }in the hook’s frontmatter per hook; on expiry the fire fails andctx.signalaborts - Hot reload — re-imported by content at every fire, so an edit reaches the next event with no restart; a gate is found from its hook, and a stray one (under
constitution/or loose infeatures/) fails boot like any entity file - Doctor
Hook gates— fails on a gate that does not load, warns on a gate with no.hook.mdbeside it; Atlas shows the gate file on the hook’s card - Not on scenario-inline hooks — they have no file to sit beside and do not run through the keyed job; their schema rejects
gate:
- Verdict —
-
Global hook dispatch — one event-log consumer, keyed BullMQ jobs — the runner consumes the entire closed event set as consumer group
hooks, so a hook added or edited after startup fires at its next matching event with no restart.- Survives restarts and deploys — an event emitted while every worker was down fires its hooks once the workers are back: the group resumes from its cursor in the event log (a gap longer than the log’s cap — about two days of a busy brain — is reported at start)
- Once per emission across replicas — each event reaches one worker replica, and matches enqueue
hook-triggerjobs keyed byhookTriggerJobId(hookId, <log entry id>), so a redelivery collapses onto its first fire while the same event emitted again (a retried run failing twice) fires again; retries and a DLQ for free - Hot reload with a bounded probe — the hook loader re-reads
features/at fire time under a 1 s TTL (the engine default forcontentProbe.hook, brain-tunable), and a lazybyEventindex is rebuilt only when the loader cache version moves - Boot-time dead-hook lint — a hook naming an event outside the closed set can never fire, so it is warn-logged once at startup and caught on demand by
omnislash doctor
-
Scoped (per-run) hooks — a workflow run registers its scenario’s hooks under its
runIdviacreateScopedHookRegistry, auto-filtered to that run’s events and routed to the scenario’s own surface.- Keyed replace on register — a Restate replay or a recovered attempt cannot stack duplicate subscriptions
- Grace-delayed unregister —
SCOPED_HOOK_UNSUB_GRACE_MS(2 s) so the caller’s just-emitted terminal event still delivers to the hooks that exist for it - Single-replica contract — scoped hooks execute inline in the pub/sub handler (no dedup jobId) and are exactly-once only because
pipelinesruns single-replica
-
Per-chat launch limit —
chatLaunches— ascope: firehook on an event that names a chat launches a fresh agent per fire, so a busy group would launch one per message; at mostperMinutefires per (hook, chat) per minute launch (engine 6,hooks.chatLaunches.perMinuteinconstitution/MANIFEST.yaml,chatLaunches: { perMinute }in the hook’s frontmatter), the rest end ashook:skipped(reason: 'rate'). Counted in the worker’s Redis rate limiter, across replicas; ascope: chathook is not limited — its messages queue in one agent’s inbox. -
Row-per-fire hook agents — every fire mints a fresh agent with
groupKey: hook:<id>andsource: HOOK, so the dashboard renders per-hook run strips off plainagentsrows the same way heartbeats do;runInmust be declared in frontmatter because a hand-setrun_incannot survive to the next fire. -
Per-chat hook agents —
agent.scope: chat— instead of a fresh agent per fire, the hook keeps ONE agent per chat the event happened in and feeds every fire into its inbox (agent.send), so the conversation carries over; the guide is telegram-account.md § One agent per chat.- Only on events that name a chat — the event’s payload schema must require
account+chatId(today themtproto:message.*family); otherwise the hook is skipped at load with the reason, whichomnislash doctorlists - Identity — one agent per (hook, chat), found by a name derived from the hook id and the chat key
mtproto:<account>:<chatId>, its own lineage (never reaped as a spenthook:fire), created with the hook’sAgentSpecon the chat’s first event; a create race between replicas adopts the winner - Acts as the account holder, held to the chat — the turn runs with
subjectIdtg:user:<holder>(the account’s own Telegram user) andauth.mtprotoChat, which everytelegram_*tool enforces: the chat’s account is used, and another chat, account-wide tools or another account are refused (CHAT_SCOPE); no delivery surface — engine notices never go into the counterpart’s chat - No outcome notice — the agent outlives the fire, so failures show on the agent itself (dashboard), not as a
⚠️ Hooknotice
- Only on events that name a chat — the event’s payload schema must require
-
Split delivery ownership on a hook fire — the agent owns its report (it calls
notification_senditself, so a skill shared across heartbeat/schedule/chat never double-posts), and the engine owns the outcome: a clean success is silent, whilefailed/cancelled/interruptedposts a compact⚠️ Hook "<name>" — agent <name> <status>notice with the error and the last partial assistant output.awaiting_inputis never reported as a failure. The notice is announced from the fire’s run record once the agent’s turn ends, by any worker replica, so a restart mid-turn does not lose it. -
Hook-level declared grants — a hook’s
grants:block is materialized byissueDeclaredGrantsbefore the prompt is handed over, under the auth an approve in the fire’s turn would mint from:(hook, <id>)for the defaultagent.scope: fire(hookIdstamped on the fire — one standing row covering every fire,maxCallsa per-refresh budget like a heartbeat’s),(agent, <chat agent>)foragent.scope: chat(that one chat — never every chat the hook serves). -
Hook lifecycle events —
hook:enqueued(fired once the hook’s agent turn is enqueued — not when it finishes — withdurationMsfor the gate and dispatch) andhook:failed(with the error), emitted identically by the global runner and the scoped registry;hook:skipped(reason: 'gate'when a gate said no,'rate'past the per-chat launch limit; asystem-category event). -
Manual hook trigger —
omnislash hooks trigger <id> --payload '<json>'fires a hook by id with arbitrary template variables for testing. -
Hook ops and Test-fire —
hook_get(feature:read: the switch, the event with its catalogue entry — fields and a sample payload —, the filter, the gate file with its sha, the agent, the prompt template and its{{placeholders}}; a scenario-inline hook is<scenario>.<slug>and reads its scenario’spath/sha),hook_read_file(feature:read, since 1.23.0: a standalone hook’s whole.hook.mdas written with its sha; an inline hook has no file of its own),hook_update(feature:write, typed keysname/enabled/event/agent/promptTemplate— an inline hook’s write lands in its item of the scenario’shooks:; the filter and the gate are raw edits),hook_write_file(a standalone hook’s whole.hook.md, validated by the hook loader),hook_widen(feature:widen, HITL — the write that adds to itsgrants:).hook_start(run:execute) is the Test-fire:dryRun: trueruns the filter on a payload (given — checked against the event’s schema — or the event’s sample) and says whether it passes and why not; a commit fires the hook as an event would (gate, then agent) and answers the fire’s run id, its trigger the requester. Refused for a hook switched off, an inline hook (it hears only its scenario’s runs), and a payload the filter rejects. -
App lifecycle hooks —
projectConfig.lifecycle.onStartup/onShutdownrun per service (bot, worker, scheduler, MCP, webhooks, pipelines) with errors logged and non-fatal.
Memory System
-
Persistent semantic memory as markdown in git — memory is a directory of
.mdfiles (per line:<line>/MEMORY.md+<line>/YYYY/MM/YYYY-MM-DD.mddaily logs) indexed into a SQLite database with vector and full-text search, committed and pushed by the brain-sync daemon.- Two path convention modules —
daily-path.ts(dailyLogRelPath,parseDailyLogDate,isDailyLogPath,isPriorityMemoryPath,isPathReadable) is the single source every consumer derives from, and every path it speaks is workspace-relative with the line first — one path space for readers and mutators alike; the full date stays in the basename so filename search and temporal decay key on a self-contained stem;period-path.tsowns the summary shape (one directory level to the daily log’s two, so the two can never collide and period levels needed no migration) - Layout migrations — two idempotent one-shots run in era order at startup:
migrateMemoryScopeLayoutmoves a pre-line workspace root (MEMORY.md, datedYYYY/, loose*.md— never the index) into the default linemain/, thenmigrateDailyLayoutfolds flat daily logs intoYYYY/MM/inside each discovered line, both re-indexing from the content-hash embedding cache and landing as git renames - Directory comes from
ProjectConfig.paths.memory— never an env var
- Two path convention modules —
-
Scoped memory — a line per agent, one index for all of them — a memory line is a directory segment under
memory/(memory/research/MEMORY.md,memory/research/2026/06/2026-06-22.md), declared on the existing config ladder aswriteinAgentMemoryConfigSchema, so an agent gets a corpus of its own without a second index, a second writer or a single new read parameter.- Isolation is structural, not a prompt request — a mutator’s containment root is the caller’s line (
resolveScopedMemoryFile: workspace path space, line-root boundary), somain/MEMORY.mdor../main/MEMORY.mdfromresearchis refused by the same..rejection that refuses an authored source; the name itself is one lowercase segment (MemoryScopeSchema) and can never address a second level, with four-digit names reserved so a line cannot collide with the pre-linememory/YYYY/layout - Write own line, read by policy, in two strengths —
recall(default[],'*'= all LINES) widens the every-turn recall sweep,readwidensmemory_search/memory_readalone, and recall implies read; two named resolutions (resolveRecallScopes/resolveReachableScopes, both feedingisPathReadable) filter candidates before ranking somaxResultsis spent on readable hits. So a line an agent should be able to consult need not be a line it is shown every turn.MEMORY.md, the daily window and the period ladder stay pinned to the agent’s own line, and curation fan-out exists nowhere, by any config —writeis a single line by type; what an agent may do elsewhere is the narrowerreportbelow - One agent curates one line, may REPORT into others —
memory.report: [<line>…]opens lines for DEPOSITS:memory_write({ line })appends to that line’s daily log (already its curator’s inbox) and nothing else —prioritywith a foreign line is refused before enqueue,memory_edit/memory_deletestay home-only — and the worker signs the entry AFTER dedup with a trailing— <role> · <heartbeatId>, carried asoriginon thewritejob and onmemory:written. Meant for the SPEC tier (a heartbeat’s / hook’sagent.memory, where the grant sits three lines above the prompt that names the line); the same bag parses on a role or brain-wide and doctorAgent memorywarns there. Never inherited by the flush curator or anagent_tasksub-agent, widens nothing on the read side, lines only (no authored corpus, no'*'), and every entry must be a line somebody curates (validateReportRefs— boot-failing like a badread) - The set rides the context, the argument selects within it — the line an agent curates rides
ToolContext.memoryScopeand the lines it may deposit into rideToolContext.memoryWriteScopes(CLI agents via the MCP URL traits, API agents viaAgentConfigBase),memory_write({ line })must name a member of that set or is refused with the allowlist (writeTarget, thememory.*ops’ gate), andMemoryWriteJobDataSchemarequiresscopewith no default — so the model can never widen its lines and a producer that forgot one fails the job - Borrowed for work done on another agent’s behalf —
AgentRunOptions.memoryScopeis a per-dispatch tier above the role, applied inresolveLazyRoleConfig: the post-run flush curator and anagent_tasksub-agent write and read the line of the agent they serve, so conclusions never land in the curator’s own corner - Authored corpora are reached by name, like lines —
read/recalltake a declaredmemory.sourcesentry or a path inside one (content,people/maria,handbook.md) beside a line name, matched by whole segments (people/marianever openspeople/maria-old/);'*'is the LINES wildcard and opens no authored corpus, so a document corpus is shared because somebody named it and never because a wildcard forgot to exclude it - Declaring indexes, naming reaches —
auditMemoryReachruns at worker boot and behindomnislash doctor’sMemory sources: a ref that names neither a line nor a declared source (a misspelling, or a bare parent of the declared entries) fails the boot naming the role that wrote it, as does a source whose name is also a line, and a source neither the manifest nor any role names is warned about by name - Dashboard browses one line at a time —
memory.overviewenumerates the first-level directories (a line exists because something wrote into it — no registry, no CRUD)
- Isolation is structural, not a prompt request — a mutator’s containment root is the caller’s line (
-
Two-zone model — managed vs authored — the boundary is structural (directory membership), not a flag: every engine mutator resolves paths under a root, so a source declared in
memory.sourcesis physically unaddressable bymemory_edit/memory_delete/ the compactor while still being fully indexed and searchable. The managed zone carries a second axis inside it — the line — enforced by the same mechanism one level in.- Authored sources are indexed for everyone and retrieved by whoever named them — vector + keyword search and
memory_search/memory_readtreat them exactly like managed files, once a role (orconstitution/MANIFEST.yaml) puts the declared entry, or a path inside it, inread/recall - Dated files in an authored vault are not daily logs — since a managed log is
<line>/YYYY/MM/YYYY-MM-DD.md, an authored path is a structural match for that shape, and the traversal guard inparseDailyLogDate(authored paths lead with../) is what keeps temporal decay and auto-load off them - It is a tool boundary, not an ACL — an agent with file-edit tools can still write there, and in a live-checkout brain those edits are committed by brain-sync
- Authored sources are indexed for everyone and retrieved by whoever named them — vector + keyword search and
-
Hybrid retrieval — every candidate is scored
0.7 × cosine + 0.3 × keyword coverage(the share of the query’s words the chunk holds), deduplicated by chunk, temporally decayed and MMR-reranked, then cut once atminScore(recallScorefor active recall) — no second, relaxed bar. A keyword candidate outside the vector top-K gets its real cosine from the stored embedding, not 0.- Vector search —
sqlite-vecvec0 KNN when the extension loads (embedding MATCH ? AND k = ?— the only indexed plan vec0 has — against a table declaringdistance_metric=cosine; legacy metric-less tables are rebuilt in place on extension load), with an in-memory JS cosine fallback; vector rows are scoped by embedding model, FTS deliberately is not, so lexical recall survives a model swap - Temporal decay — exponential relevance decay on daily-log age, enabled by default with a 30-day half-life
- MMR re-ranking — diversity-aware selection (lambda 0.7) so results don’t collapse onto one document
- Stemmed keyword leg — one analyzer on index and query (NFKC, lowercase,
ё→е, the configured language’s stop words dropped, Snowball stem), soвстречаfinds «о встрече» andreportfindsreports; the query is an OR of its content terms (a shortened stem matches as a prefix), BM25 only picks candidates and the score is coverage, which means the same at any corpus size.defineProject({ memory: { languages } })picks the stemmers — 27 languages, default['english'], one per script (an unreachable entry fails the load); a change rebuilds the index with no embedding calls. Text without spaces (CJK, Thai) gets no word splitting — the vector leg carries it - Keyword-only degraded mode — without
OPENAI_API_KEYthe manager still runs (indexing short-circuits), and a query embedding that fails leaves the keyword and path legs answering; either way the missing leg scores 0 under the sameminScore, so a keyword match alone stays under the default bar (chit-chat cannot ride every turn while the embedding API is down) and surfaces throughmemory_search’sbelowThresholdlever - Search by filename/path —
searchPathmatches query terms exactly against the full path, basename and stem (three tiers), trims prose punctuation around terms, and seats hits at the front of the final list pastminScore, decay and MMR; a path hit carries noscorefield at all —MemorySearchResultis a discriminated union onmatchedByso every consumer must branch
- Vector search —
-
Context delivery — four parts on three schedules —
MemoryFactory.buildMemoryContextreturnspriority,periods,recentandrecallseparately, and volatility decides placement:MEMORY.mdand the period ladder are two session-envelope sources (<agent-memory path>,<agent-memory-periods line>), each re-sent alone only when it changes, the daily window rides transcript-less steps only, the clock and recall ride the per-turn envelope. On an API agent the samecoreis a standing system message after the cached system prompt and the daily window rides every turn’s message.- Active recall — the user’s prompt is the search query each turn, formatted into a
### Recalled from Memoryblock, best-effort, clamped to the embedding model’s input limit in estimated tokens, and deduplicated against the agent’s own line’sMEMORY.mdwith excluded hits freeing their slot rather than shrinking the set - Per-agent memory config — four tiers (engine defaults →
MANIFEST.yamlmemory:→ the role’s own top-levelmemory:block →agent.memoryon a heartbeat / hook,AgentSpec.memory, the same sparse bag, honoured on the agent-scope paths only), one flat bag answering which lines (write,report,read,recall) and how much of them (priorityChars,periodCount,periodChars,recentDays,recentChars,recallResults,recallScore,recallChars,searchScore); every knob is numeric and switches its part off at0, andwritealone takes a fourth per-dispatch tier - The block merges one level —
resolveMemoryConfigreplaces each key whole and keeps the ones a tier did not name, so narrowing the window doesn’t reset the brain’s recall tuning (resolvePromptFlags, for the prompt modules, replaces every flag wholesale with no exceptions); both the manifest block and the frontmatter block are strict, so a typo’d knob fails the load - Char ceilings bound the rendered block — day labels, the “older entries” pointer and the recall heading ride the budget, and a budget too small for its own framing carries nothing at all
- Memory is declared — a role (or the heartbeat / hook running it,
agent.memory) has memory only when it carries amemory:block ({}counts); without one it gets no module, envelope, recall, memory tools or flush, and never reads or writes any line — a borrowed dispatch line (agent_task) never turns it on.resolveMemoryConfigreturnsnullfor it, and the wire carriesmemoryEnabled: false+memoryWriteScopes: [].promptFlags.memoryis gone (a stale key is stripped; doctor Role declarations names it and lists every role that runs without memory). A top-levelmemory: falseis not an object and fails the strict parse, which drops the whole role file - Switching memory off withdraws it — a live CLI session whose role stops declaring
memory:gets one<agent-memory>withdrawal in its core envelope, keyed on the core mark (no mark ⇒ nothing was sent ⇒ no withdrawal); an API turn rebuilds its context every request and needs none
- Active recall — the user’s prompt is the search query each turn, formatted into a
-
Write path — serialized through a queue, deterministic on
MEMORY.md—memory_writeenqueues onto thememory-opsqueue, a single-concurrency worker performs the file write and index sync, and the tool waits for that worker’s verdict before answering.- The answer is witnessed, or says it is not — the worker’s own message on success (parsed against a per-op schema, since it crossed Redis; a duplicate is not silence but
No new content (llm-noop)), its error on failure, and an explicitUNCONFIRMEDwhen the worker dies mid-job, the lane is backed up past the cap, or the agent’s turn is cancelled — whose reason names the job’s own state (active for 95svsnever started — 3 job(s) ahead of it), so “the cap is too low” and “the lane is dead” are different sentences. A fourth answer covers a queue nobody is consuming: the job is still QUEUED DURABLY and runs when a worker starts, so the agent is told the memory is not yet updated and not to retry — refusing to enqueue instead once turned an ordinary second of a deploy (the worker is stopped) into permanent loss of the write. Four answers, not two: anUNCONFIRMEDjob may still land, so it tells the agent to re-read withmemory_readrather than to assume — reported as a tool failure, because every memory op is safe to repeat (writemeets the dedup,editfinds nooldStringif the first landed,deleteis idempotent) and the one thing the agent must not do is proceed as if the write were done. Before this, all three arrived assuccess: "queued"and a failed write was invisible to the agent that made it — its turn was over and the error lived only in the job’sfailedReason.memory_editandmemory_deleteshare the contract. - Awaiting the verdict is why the queue runs at one attempt — the two are one decision. A job with retries left goes to
delayedand emits no failure event, so a waiter never sees the first rejection: it waits out its whole window and answersUNCONFIRMEDfor an error the worker already produced. Nor could the retry help — a rejected op is deterministic, and one that threw after writing re-runs against a file its own first attempt changed. The caller (the model, or the person at the dashboard’s Save button) is the retry mechanism, and the wait itself is a liveness cap sized against the concurrency-1 lane (90s: an agentwriteahead of you is entitled to its full 60s LLM dedup budget plus an index sync), not a deadline the answer depends on. - One writer, no exceptions —
memory_write_file(the whole-file save — an agent’s, or the dashboard’s editor through the same op) enqueuesop: 'replace'and awaits the same worker rather than writing the zone itself, which is what lets that op compare a file’s mtime against the revision the draft branched from (expectedModifiedAt) and then write it as one indivisible step; a mismatch comes back as a refusal (conflict), never a lost update. It keeps one thing the appending writes do not: a worker-count check BEFORE enqueueing, so the content stays with its caller instead of being queued into a lane nobody is draining - Every write lands in the daily log;
priority: trueadditionally appends a list entry under today’s## Memory (YYYY-MM-DD)engine-appended section - LLM deduplication of the daily-log half —
deduplicateFileWriteContentis a keep-or-drop gate, never an editor: the model returns a verdict only, and an entry that adds any substantive fact lands VERBATIM, whole, even when part of it repeats the file (strategiesnew-file/llm-keep/llm-noop/fallback-append/fallback-large); one that adds nothing is dropped and the agent is toldNo new content (llm-noop). It once returned “only the new information” — a fact-level delta stripped of the subject, date and topic that made it readable, appended as an orphan block and reported as a plainWritten. A partial duplicate is the curator’s to fold; a fragment is nobody’s to understand - One HARD budget for every writer —
memory.fileMaxChars(default 10000,0= unbounded; per-line override atmemory.lines.<line>.fileMaxChars, since one number cannot serve a 50k curated line and a list of gotchas) bindsmemory_write,memory_editand the dashboard’s whole-file save alike, in two steps. FirstcompactMemoryForBudgetdrops the oldest dated engine-appended sections, protecting the section the current write landed in; drops are loud (memory:compacted+ dropped dates in the tool result). Then, when every droppable dated section is gone and the file would STILL be over, the write is refused — nothing written,memory:budget-refusedemitted, and the numbers named on the caller’s own channel (tool result / thrown error / a structuredover-budgetresult for the dashboard). A write that SHRINKS an over-budget file is still accepted, which is how a curator digs the line back out. A budget on the FILE, not on the prompt — what a turn pays forMEMORY.mdis the reader’smemory.priorityChars, which bounds the<agent-memory>block the same wayrecentCharsbounds the daily window, drops the oldest dated sections to fit, clips the curated overflow, and says out loud when the block is partial - Shape gate — a dated section is droppable only while its whole body still matches the emitted shape; one that has been rewritten counts as curated and is never dropped, because the engine cannot know what it means now
omnislash doctor→Memory budget— the write path cannot push a line over its budget, but a direct file write by an agent holding file tools, or a loweredfileMaxChars, can; the check names every line whoseMEMORY.mdis over, before the next priority write is refused, and on a passing brain it reports each line’s reading rather than a tally- The budget reading, on every hand-off of the file —
8794 of 12000 fileMaxChars (73%)rides the<agent-memory>block’s label, amemory_readof aMEMORY.md, and the tool result of a write or edit that touched one — the three doors a CURATOR has, since the doctor check that also reports it needsruntime:read, which a curator role typically does not hold. The owner’s file view in the dashboard reports the same reading where every other memory file shows disk bytes. The curator that owns the distillation cannot count the chars it is looking at, and the shell count it would otherwise reach for (wc) returns BYTES — ~1.6x the budget’s unit on a Cyrillic corpus, which is a whole distillation pass spent on an overflow that never happened. Every size the memory events carry is in the same unit for the same reason, andmemory:compactednames the line it happened to plus the sizes either side of THE DROP — sobeforeChars - afterCharsis the content the budget cost, never the operation’s net change - No LLM ever rewrites
MEMORY.md— the engine owns mechanism (structure, budget, section shape, tools, events); deciding what memory means — including which curated section to shrink when the budget refuses a write — is the brain’s curator’s job memory.priorityDefault— makesmemory_writedefault to priority when the caller omits it
- The answer is witnessed, or says it is not — the worker’s own message on success (parsed against a per-op schema, since it crossed Redis; a duplicate is not silence but
-
memory_searchanswers with evidence, not just rows — the result is{hits, degraded, belowThreshold, nearestBelow, truncated, minScore}, the same ideadb_queryapplies (every statement’srowscarry atruncatedbeside them) and for the same reason: a bare empty list cannot tell “I looked and there is nothing” from “I could not look”, and a model reading one has no choice but to assume it looked. An emptyhitsis ABSENCE whendegradedis empty; a non-emptydegraded(blank query, an index nothing can be indexed into, a failed index sync, no retrieval path, no embedding provider, failed query embedding, FTS down, or the search itself failing) means NOT CHECKED.belowThresholdandtruncatedare not caveats but LEVERS for a caller who can pull them: material exists under the bar (search again atnearestBelow), or more cleared the bar thanmaxResultshad room for (raise it) — the tool reports both, and every-turn recall reports neither, since its numbers are the brain’s own configuration. An empty index is not itself a degradation — a brain that has written nothing gets a fair miss and silence, because a NOT CHECKED on every turn of every new brain is how a caveat stops being read; what counts is emptiness the sync cannot account for. The notice also drops the “search again” advice when one reason VETOES retrieval (index-not-built,no-retrieval-path), since the tool would read the same dead index — one blocker settles it, whatever else is on the list. A query that names a file is still answered even then: the path leg needs neither an embedding provider nor FTS. Anoteis attached to the result — never to the tool description — only when that particular answer would otherwise be misread, so the caveat costs a turn nothing when it does not apply. Active recall follows the same rule and applies it to hits too: a degraded miss adds a short### Memory recall — NOT CHECKEDblock, degraded hits carry a one-line_INCOMPLETE — …_footer (the more dangerous half — a short list of real memories reads as the whole record), and a fair miss stays silent. The caveat is charged torecallCharsahead of the hits and is never the thing that gets dropped;recallChars: 0switches the whole block off, like every other zero in the memory budget. -
Memory tools — one
memory.*op family — every memory read and write is an engine op with its tool and its dashboard procedure:memory_overview(each readable line with its counts per kind, size, newest files andMEMORY.mdagainst its budget; each authored source with the roles that read it; the memory settings),memory_list(one line’s or one source’s files, complete, each with who last wrote it through a memory command),memory_read({ path, content, kind, chars, sizeBytes, modifiedAt, budget }—budgetwith its reading in words on aMEMORY.md—, a line range on request),memory_search(mode: 'semantic'by default, or'literal'— every file whose path or text holds the query, no embedding needed;line/sourcenarrow it),memory_write(memory:write),memory_edit(memory:edit, exact-string replace that fails loud unlessoldStringis unique),memory_write_file(memory:edit, the whole file underexpectedModifiedAt),memory_move(memory:edit, a new path inside the agent’s own line; never a line’sMEMORY.md),memory_delete(memory:delete,dangerous+ HITL — git is the only undo). A memory path is the line first (main/2026/06/2026-06-22.md) or an authored source’s brain path (cases/acme/brief.md) — one path space for readers, recall and mutators alike — and no schema carries the caller’s reach, since that is execution context: reads reach the agent’s readable lines and sources,memory_write({ line })selects within its writable set, edit / rewrite / move / delete address its own line, an authored source is read-only to everyone, and a refusal comes before anything is queued. Every memory write is a row of the action log (audit_list). The five original tools arealwaysLoad, and every memory tool is offered only to an agent whose role declares amemory:block — any other agent is refused them,'*'notwithstanding. The dashboard reaches every line through the same ops. -
Self-healing multi-process index — several processes hold independent handles on one SQLite file in WAL mode without serving ghosts.
- Inode freshness check —
ensureFreshDbHandlecompares dev/ino before every search, sync, status and index removal, reopening after another process swapped in a rebuilt DB - Safe full reindex — build into a temp DB seeded from the old embedding cache, then atomically swap and reopen; triggered by a model/provider/
providerKey/chunk-config change or newly-available vectors - Incremental sync by content hash — unchanged files are skipped; chunking is 400 tokens with 80 overlap, embeddings are batched (≤8000 tokens) with 3-attempt exponential backoff and cached in SQLite keyed by
(provider, model, providerKey, hash) - Embedding calls sit behind the shared provider circuit — both embed paths funnel through one place which checks the deployment’s circuit breaker before the call and records ONE failure per CALL, never one per retry. The circuit is memory’s own —
embeddings:<provider>, never the LLM provider’s id — so a failing memory batch can never start skippingopenaisteps in an agent chain. While it is open a recall skips the doomed embedding and answers from its keyword and path legs in milliseconds (query-embedding-failed) instead of burning the 60 s query timeout, and a sync fails fast intoindex-sync-failed. It is also the recorddoctor_check→Memory embeddingsand the dashboard provider panel read: before it existed, an embedding provider outage was invisible to every engine surface - Watcher where it belongs — chokidar runs in the worker (single durable writer) and standalone MCP; bot, pipelines and dashboard read through WAL, with a
watchDebounceMs1.5 s debounce coalescingadd/change/unlinkinto one sync - Startup dirt + sweep — the manager always starts dirty so offline edits fold in on the first sync, and aged
.tmp-*/.backup-*rebuild leftovers are swept
- Inode freshness check —
-
Period levels — weekly / monthly / quarterly / yearly summaries as derived memory — a closed calendar period gets one file in the same line (
<line>/2026/2026-W34.md,-08,-Q3,2026.md), summarized from the level below it (daily logs belowweek), and the latest of each level is carried into the agent’s context as<agent-memory-periods>. Off until the brain names lines inmemory.consolidation.- Mechanics engine, judgment brain — the engine owns the calendar, the cascade, idempotency, the tick budget and the write; the text comes from a
system:consolidatorturn whose role file the brain overrides by placing its own copy at the same path - The file IS the idempotency record — no ledger, no marker, no “last consolidated” column: a period with a summary is done, a failed run writes nothing and stays due for the next tick, and re-doing one is
memory_deleteplus a tick; the write enforces the same premise it was planned on by being create-only, so a summary the owner edited during the model run is never overwritten by a result that was already stale when it landed - Cheap when there is nothing to do — one directory sweep a day; a period the brain never wrote in produces no file and no model run, and the cascade waits a tick rather than summarizing a month from weeks that do not exist yet
- No lookback window, a nightly rate instead — the planner enumerates periods from the line’s own history, so a brain adopting consolidation late backfills its whole ladder at
maxRunsPerTicka night and the tick reports what is still queued; dueness is containment (did the brain write INSIDE the period) while sources are ownership, which is what stops a straddling ISO week from making a period the brain never lived in look due - The model is three tiers, none of them the role fork —
lines.<line>.modelRef→consolidation.modelRef→ therole-consolidator.mdfrontmatter (smart); the block tier exists because a role override replaces the engine’s file whole, so changing only the model would otherwise fork the voice, the criteria and the capability floor with it - The model never names a path — the run returns structured
{ content }and thememory-opswritePeriodop builds the destination from the calendar, so the single-writer invariant holds for the one write whose text came from an LLM - Sources are inlined and a week belongs to the month it ends in — the prompt carries the source files verbatim (identical for CLI and API harnesses, capped with the drop disclosed); a period’s sources are the lower periods that end inside it plus the daily logs of its tail, so ISO week 2026-W31 (27 Jul – 2 Aug) feeds August, W36 (31 Aug – 6 Sep) feeds September, and August takes 31 Aug from its daily log — a month never waits on a week that ends in the next one
- Written to fit the ladder, clipped honestly when it does not — each run is asked for an even share of the brain’s
periodCharsacross a full ladder (periodSummaryTarget, no new knob; over it is filed as written and reported asoverTarget), and a summary that still does not fit arrives as whole lines (or its first words with…) under a(in part — memory_read for the rest)label, never as a bare heading - Decay on the period’s own timescale — a summary is dated by its last day and its half-life is scaled by
PERIOD_LENGTH_DAYS, so a fresh weekly ranks like a fresh daily and ages seven times slower;MEMORY.mdstays the only evergreen file - Not the deleted dreaming subsystem — calendar trigger instead of a statistical gate, a new file no one else owns instead of an LLM rewrite of
MEMORY.md, an overridable role template instead of an engine-hardcoded prompt, zero hot-path cost instead of a lock permemory_search, and amemory:consolidatedevent instead of silence - The event reports the WRITER’s verdict — the tick awaits the
memory-opsjob, sowrotenames a file that exists,skippedsays why nothing was written (a newer summary already owns the path, or the sources vanished) andfailedleaves the period due for the next tick. Three shapes, one per outcome, so “present only onwrote” is a type rather than a comment.droppedonwroteis a set difference — every source the summary does not cover, whether it was over budget or gone by the time it was read
- Mechanics engine, judgment brain — the engine owns the calendar, the cascade, idempotency, the tick budget and the write; the text comes from a
-
Memory flush — post-execution curation — after an agent turn, a one-shot curator agent is handed the conversation (or prompt+result) and saves only corrections, gotchas, durable preferences and significant decisions; the curator’s standing instruction is the BODY of
constitution/Agents/system/role-memory.mdand its model and capabilities are that file’s frontmatter, so one overridable role file is the whole description of who the run is and what it may do.- Best-effort, with no deadline of its own — an ordinary agent turn, bounded by the shared stream-idle watchdog (
AGENT_STREAM_IDLE_TIMEOUT_MS) rather than a flush-local timeout that would kill healthy flushes queued for a concurrency permit; master togglememory.flush.enabled, per-rolememoryFlushfrontmatter — and only for a role that declaresmemory:(its write set is otherwise[], which the flush gate skips) - Per-agent criterion, on top of the shared curator — one curator role reads every agent’s transcript, so
memoryKeep(agent frontmatter →role-general.md→ nothing) hands it this agent’s stated definition of worth, rendered above the transcript. It narrows what the curator looks for and cannot lower its bar — an unmatched turn is still"done"— so the discipline stays in the role body where a brain replaces it wholesale - Fire-and-forget, not unobservable — every flush reports a
MemoryFlushOutcome(wrote{tools}/nothing_saved/skipped{reason}/failed{error}) as one log line plus anagent:memory_flushedevent, so a curator that has quietly stopped saving anything is alertable as a streak ofnothing_savedper agent or heartbeat - Classified by mutations, not tool use — only the memory commands (
memory_write/memory_edit/memory_write_file/memory_move/memory_delete,MEMORY_MUTATION_TOOL_NAMES) count, so a curator that searches memory and then answersdonecannot read as productive;bareToolNamenormalizes the CLI’smcp__omnislash__*spelling so the verdict is harness-independent
- Best-effort, with no deadline of its own — an ordinary agent turn, bounded by the shared stream-idle watchdog (
-
Git-backed sync — the brain-sync daemon commits and pushes the resolved G-set (
memory,constitution,skills,features∪brainSync.additional) on a 5-minute fetch → commit → rebase → push tick, holding the tick while a resolver agent owns the tree and escalating rebase conflicts into a resolution episode. -
Configuration and observability —
memory.embeddingModel(swap it and the meta mismatch triggers a safe reindex),memory.sourcesandmemory.languages(same rebuild, embeddings served from the cache) inomnislash.config.ts;OPENAI_API_KEY,DEBUG_MEMORY,DEBUG_MEMORY_EMBEDDINGSenv; eventsmemory:written,memory:edited,memory:deleted(each naming the line it touched and its size inchars, the unitfileMaxCharscounts in),memory:moved(from,to, both lines),memory:compacted(beforeChars/afterChars),memory:budget-refused,memory:synced,memory:consolidated,agent:memory_flushed. -
Agent structured state — Postgres tables the agent models,
<namespace>_*views the engine renders into its context — the other half of “what an agent remembers”, and deliberately not memory: memory is prose in files, state is rows an agent counts, filters and updates. A role declaresstate: { namespace: nova }(and must holddb:read+db:write, or'*'— the role load fails otherwise) and any VIEW inpublicnamednova_*is rendered into<agent-state>on every turn; the agent creates and evolves the tables underneath withdb_executeDDL, inside a turn, with no deploy and no migration. Small → straight into context, large → a table (tables never render) plusdb_query. Postgres computes the summary; nothing summarizes it with an LLM. Full model: state.md.- The catalog IS the registry — no declaration file, no registry tool, nothing to drift:
pg_class(relkind = 'v',relname LIKE '<ns>\_%', ordered by name,obj_descriptionas the subtitle) answers what the block contains, so aCREATE VIEWadds one and aDROP VIEWremoves it with nothing else to update. Tables are never rendered; materialized views are out of scope until a refresh story exists (db_schemalists them), because a matview is stale until someone refreshes it and “your state” must not be a confident lie - Its own source in the session envelope, one section per view — the render is recomputed each dispatch and each view hashed on its own against
agents.context_marks: a row written into one view re-sends that view alone, as<agent-state namespace view as_of>opening with(update — replaces this view …; other views stand); a dropped view gets a one-line tombstone;MEMORY.mdnever rides along.as_ofrides the tag, deliberately outside every hash — inside it, every dispatch would differ and the whole block would go out every turn - Switching state off withdraws the block — a role that drops its
state:or lands on a zero budget sends one<agent-state namespace>tombstone (structured state is switched off …); an agent that never received a block receives no withdrawal - Ownership is the prefix, not the history — the prompt module tells the agent that everything in
publicnamed<namespace>_*, tables and views alike, is the namespace’s to create, alter and drop (the prompt renders thedb_schemapattern that lists it, every_escaped —sec\_ops\_%forsec_ops— from the samenamespaceLikePatternthe render’s catalog scan uses), that nothing else is its toALTERorDROP(rows it still writes where its instructions say so), and never toCASCADE, so Postgres itself refuses to drop what a view still reads. A rule checkable from the name at any moment, replacing “neverDROP/ALTERwhat you did not create”, which rested on history a fresh session does not have. Prompt-level only; the hard gate isdb:writeathitl: true - The re-inject rule is a contract with the view author — so the prompt module demands determinism: every view ends with
ORDER BY(a heap scan reordered by anUPDATEflips the hash for nothing) andnow()/clock_timestamp()/random()never appear in a select list (time belongs in aWHERE, so the view changes when the fact changes) - Three budget tiers,
0on any of them = the block off —StateConfigSchema.default()s (views8,rows20,chars4000) →MANIFEST.yaml§state(numbers only — a brain-widenamespaceis a collision by construction and fails the load) → the role’sstate:block, one-level merge throughresolveStateConfig, a heartbeat inheriting itsagent:role’s block. The gate is the declarednamespace, NOT the memory declaration: an agent without memory can still carry state - Honest about its own edges —
(no rows)for an empty view,… +1 or more rows — db_querypastrows(one row is read past the budget, so “at least one more” is the only fact a secondCOUNT(*)per view per turn would buy), trailing views dropped WHOLE atcharsand named, a nameless… +1 or more views not shownpastviews, and a broken view rendered as ONE marked line! <SQLSTATE> <message>(the full driver diagnostics are unbounded, so a broken view rendering them could spend thecharsbudget the healthy views need;db_queryon the same view still answers withDETAIL/HINT) — one transaction per view, in parallel, so a single failure neither aborts the others nor turns worst-case turn latency intoviews × statement_timeout - Per-agent scoping without a second declaration — every state transaction (the render, and
db_query/db_executewhen the caller is an agent) binds the calling agent as a per-transaction GUC, never as a tool argument, so a view that must tell apart several live agents of one role filters onomnislash.current_agent_id()— NULL for a non-agent caller, so such a view fails closed rather than answering everybody’s rows. The engine function, not the rawcurrent_setting('omnislash.agent_id', true): that one reads''instead of NULL on any pooled connection that has served an agent /contextreports each body separately — a role withstate:gets its own card line naming the namespace and whether the block is injected or pending; a role without one shows no line at all
- The catalog IS the registry — no declaration file, no registry tool, nothing to drift:
-
Feature contexts — what a feature adds to a turn linked to it —
features/<f>/<name>.context.md: body = instructions, frontmatter =description/entity/when/data(state views, optionally{ view, by }filtered to the turn’s entity key) /chars/keep; attached only when the turn has a link to it. Thefeature-contextsengine skill carries the decision and the procedure. Full model: contexts.md.- Closed
whenvocabulary —run(a run whose reach holds the context — a heartbeat tick, hook fire, scenario task or pipelinerunAgent,AuthContext.run),chat,reply,ask,always; default[run, chat, reply, ask]; strongest link wins and is cut last - A run’s reach — every run carries the contexts it reaches, each with its entity key (
AuthContext.run = { feature, reach }): the contexts of the entity’s directory and above up to the feature root (a subfolder is a part of the feature with its own contexts), replaced by the entity’sreach:frontmatter (heartbeat, hook, scenario; another feature’s context attaches only where the role admits it), replaced per fire / step by a hook gate’s{ reach }orrunAgent(ctx, { reach });resolveRunReach, decision 0130 - Role allowlist
contexts:—['*'], names, or nothing = none (fail-closed, likeskills:);runand anaccess.yamlchat binding bypass it (explicit links: the feature tied itself to the role through its entity’sagent:, the group rule tied the chat to the context); the inferred links (reply,ask, code, acontext_bindbinding) do not - Brain code links (
code) —<id>.context.tsbeside the.context.md(defineContext({ match, render }):match(turn)→{ entity? } | nulllinks the turn,render(entity)→[{ key, text }]adds sections after the views withinchars),attach: [{ context, entity? }]in theengage()verdict, and{ entity }/{ reach }in a hook gate’s verdict (the fire’srunlink carries them — as does a pipeline step’srunAgent({ entity })/({ reach }),TerminalErrorwithout a feature run); inferred (allowlist, pin,keep), fail closed undercontexts.codeTimeoutMs(2000) with a logged reason; doctorFeature context code - Catalog +
context_open— admittedaskcontexts render as- <name>: <description>in the system prompt (stable per role);context_open(name, entity?)(context:read) returns the instructions and the views now and keeps the context attached forkeepturns; a role withoutcontext:readgets no catalog (doctor warns) - Turn-tail placement — attached contexts ride the context envelope as
<feature-context>blocks (sectionsinstructions+ one per view), hashed intoagents.context_marksper section; CLI re-sends only what changed, API renders each turn - Claimed views leave
<agent-state>— a view in any context’sdata:is excluded from every role’s state block and itsviewsbudget; it rides only with its context - Budgets, never silent —
FeatureContextConfigSchema(chars4000,keep3,turnChars12000,maxActive4) → MANIFESTcontexts:→ the file’schars/keep(contextConfigFor); views are cut by rows, then stubbed; a context whose instructions are over itschars, or past the turn’s budget (weakest link first), is a one-line stub saying how to open it - Ids and switch — id = file name, unique brain-wide (a duplicate loads neither copy); no
enabled:—FEATURE.mdswitches a feature’s contexts with it;contentProbe.contextsets the loader’s re-check window - Message origins — every bot message that belongs to a feature is recorded with it (
omnislash.message_origins: feature, reach, HITL card, agent): a heartbeat’s / hook’s post, a hook’s outcome notice, a feature run’s answer in a chat, every HITL card (a pipeline’srequestApproval/awaitApprovalincluded), and any send withabout: { context, entity? }orabout: { entity }(keys the one keyed context of the sender’s own run) —notification_send,file_send,voice_send,context.notify, a pipeline’snotify()/requestApproval/awaitApproval. A run’s reach rides every origin the run writes (its sends, its answer, its cards, its hook notice); a run whosereplyreach entries are all keyed and carry no key is refused a send withoutabout, the refusal naming the context and its key (decisions 0034, 0130). Every message such a send became is recorded — each part of a long text split for the platform, an answer’s images — so a reply to any of them links; a send tool refuses anaboutnaming no context. Kept for MANIFESTmessageOrigins.retentionDays(365,0= forever) chat— a chat or thread bound to an entity — statically in a group rule’scontexts: { <context>: <entity> }(constitution/access.yaml; a widening edit), or at run time withcontext_bind({ context, entity?, chat?, thread?, bot? })/context_unbind(context:bind, moderate;omnislash.context_bindings, re-keyed with a supergroup upgrade) and the pipeline SDK’sbindContext/unbindContext; every turn there carries the context with that entity’s key; a binding may not contradict the file’s;access_explainlists a chat’s bindings; doctorChat accesswarns on a binding naming no context or a mismatched entity key- The pin — tenant isolation — a chat bound to
C@eattachesConly with keyefrom any link (run, chat, reply, ask, a held open): another key is refused and logged, none takese;context_openwith another key is refused with the reason, without one it opens fore reply— a native reply links the turn to what it answers — the quoted message’s origin rides the inbox entry (replyLinks, unioned across merged messages) into the dispatch: the message’s recorded reach attaches itsreplycontexts, each with its entity key — the run’s reach, or the one context the send wasabout(the reply sees what the run saw); the role must admit them; the link stays held on the agent for the context’skeepturns after the reply (counted off the spend ledger)
- Closed
Skills System
-
Agent Skills standard, with omnislash extensions — a skill is a directory with
SKILL.md(frontmatter + instructions) and optionalscripts/,references/,assets/, in the same open format Claude Code, Codex, OpenCode, Copilot and Cursor read.- Required frontmatter —
name(lowercase[a-z0-9-], ≤64 chars, matching the directory) anddescription(the primary trigger the agent routes on) metadata.omnislashextensions —capabilities(informational),requires.env/requires.bins/requires.anyBins/requires.os(closed worldSKILL_PLATFORMS, fail-open on an unknown name),install[]specs,defaultEnabled,listed, and the computeddepsSizelisted: false— a specialist skill — never in askills: ['*']catalog; a role that names it inskills:still lists it, and an engine module may point at it; eligibility, activation,skill_getand the dashboard (badge “on demand”) are unaffected, and it is never counted as dropped- Top-level
enabled:is not read — the only authoring lever ismetadata.omnislash.defaultEnabled(omitted =true), always beaten by an explicitskills/.settings.jsonentry
- Required frontmatter —
-
Progressive disclosure — three levels — level 1 is the catalog of the role’s listed skills, injected into its system prompt, level 2 is the SKILL.md body loaded on trigger, level 3 is resource files fetched on demand.
- Catalog format — a
## Skillsheading, ONE load sentence for the whole catalog (CLI: read<root>/<entry>/SKILL.md; API: callskill_getwith the name), then one line per skill,- <entry>: <description>— the entry is the skill’s directory under the skills root on CLI (a feature skill’s: its path under the brain root,features/<feature>/…, which the sentence then names too) (readable even whenname≠ directory) and itsnameon API; no per-entry path. Each entry is collapsed to one line, so no skill description can open a line or forge a heading - Catalog budget — the catalog truncates each description to 250 chars on a word boundary at render, not at parse, because third-party skills’ descriptions aren’t the brain author’s to control; guidance is 100–200 chars with triggers first, since truncation cuts the tail
- Graded degradation under
maxSkillsPromptChars(30k) — drop to the per-description cap, then to the largest uniform cap that fits (binary-searched against the real render, not estimated), then to entry keys only, and only then discard the tail;maxSkillsInPrompt(150) applies first - The role’s
skills:list is an allowlist —['*']lists every eligible skill, names list only those (in the brain’s order, never reordered), no list lists none; a scenario’sskills:oragent_task’sskillsreplaces it for that run. An API agent’s catalog is sent only withskill:read(or'*'), the capability that opens a skill;skill_getstill opens a skill the role does not list by name, as long as it is enabled and eligible
- Catalog format — a
-
Two delivery modes — CLI agents get the catalog under the absolute skills root and read
<root>/<entry>/SKILL.mdwith their native Read tool (no symlinks, no writes into anyone’s working tree, identical across harnesses); API agents get the same catalog plusskill_get/skill_read_resourcetools.promptFlags.skills— one value, three states —'engine'(default: engine catalog on, harnessSkilltool left out of the built-in allowlist),'harness','both', delivering what theskills:list grants (the harness side is not filtered by the names: any list turns it on); the harness’s own skill system is off by default because it otherwise runs a second, unpoliced catalog (measured 14→22 skills depending on the repo’s.claude/skills/) that never passes through activation, eligibility or capabilities- Isolation-aware paths — under a jail the catalog is rendered outside from the live tree and addressed at the mount point via
resolveSkillsMount, so catalog and shim can’t diverge - Per-skill env injection —
collectEnvVars()gathers declaredrequires.envvalues into the agent’sskillEnv, passed to the child process/SDK runtime with dangerous names blocked
-
Eligibility filtering —
getEligible()drops a skill that is disabled, off-platform (requires.os), missing a required env var, missing anyrequires.bins, or missing allrequires.anyBins; excluded skills vanish from the catalog, fromskill_get/skill_read_resourceand from MCP resources (the load path checks the same predicate the catalog lists by) whileloadAll()still returns them for the dashboard.- “Enabled but inactive” diagnosis —
explainEligibilityclassifies the cause intomissingEnv(set env + restart),rebuildableBins(covered by a bakeableapt/uv/nodespec →omnislash build+ redeploy) andunbakeableBins(add a bakeable spec first), surfaced identically in the dashboard badge, a worker-startup warning per skill, and theSkillsdoctor check - Off-platform is not a defect — a skill excluded by
requires.osreports nothing, because “not applicable here” has no remedy - No install-on-enable by design — production containers are non-root and ephemeral, so binaries live in the image and the remedy for a missing one is always a rebuild
- “Enabled but inactive” diagnosis —
-
Two roots, one name namespace — discovery scans the brain root (
skills/<name>) first and the engine root (skills/engine/<name>) second; identity is the declared frontmattername, so the same name in both roots is a supported override where the brain’s copy wins and the engine’s keeps updating underneath.omnislash skills fork <name>— copies the engine skill into your own root with no lockfile entry, soskills updatenever touches it andchecknever calls it drift- Extend instead of forking — an overriding brain skill can read the version it replaced as the resource
engine/SKILL.md; the path-traversal guard moves to the overridden directory rather than relaxing, and the catalog entry ends with(builds on …)— the engine copy’s entry key on CLI, theengine/SKILL.mdresource on API — so both modes can reach it - Reserved name
engine— a brain that hand-createdskills/engine/SKILL.mdbefore the reservation gets a hard fail demanding a rename
-
Feature skills — a third root — a skill only one feature needs lives inside it,
features/<feature>/…/<dir>/SKILL.mdat any depth below the feature root, and ships and switches with the feature.- Visible to its own feature — in the
skills: ['*']catalog of that feature’s roles only (asystem:role has no feature, so its'*'carries none); a role elsewhere that names it inskills:gets it, as withlisted: false - The feature switch reaches it — a switched-off feature holds its skills off (catalog,
skill_get, the image bake) without touching their own activation; deleting the feature deletes them - No override across roots — a feature skill named like an existing skill anywhere is refused and the first claimant keeps the name; doctor
Skill namesfails on it (onlyskills/<name>overskills/engine/<name>overrides by name) - CLI path — its catalog entry is its path from the brain root (
features/coach/skills/drill) and the load sentence names the brain root it resolves under; a jailed turn reads it from its own checkout
- Visible to its own feature — in the
-
Engine system skills — the engine’s own corpus —
engine-skills/<name>/in the engine repo is the manifest (tsdown copies it into the CLI’s dist), mirrored into every brain’sskills/engine/onomnislash upgrade/regen.- Shipped today —
agent-state(unlisted —listed: false: the state modelling guide the state module points every state agent at, reached through that pointer rather than through'*'catalogs),artifact-publishing,build-an-operation(the builder’s decision path for an end-to-end operation — interview, stages, a ledger, pattern A, ship dark → verify → live, the map inFEATURE.md, the hand-off to a feature lead — with onereferences/file per step),chat-access(who may use a bot, data vs code, theaccess_*procedure and the rule never to open a'*'role to non-owners without the owner’s explicit yes),disaster-recovery(defaultEnabled: false, needs a monitoring stack),durable-repo-edit,event-hooks,feature-contexts(where a feature’s rules and data go when only some turns need them — the*.context.mdcontract, picking the link, the role’scontexts:allowlist, binding a chat to an entity, budgets, and checking it with doctor /agent_context//context),scenarios,scheduling,self-evolving(an owner skill: what a person asks of the loop mapped to theloop_*tools and the rails around the human gates, with the authoring depth inreferences/),sub-agent-supervision,widgets(authoring an interactive page for a person — when a widget beats a message, a HITL card or a publication, the*.widget.html+.widget.ymlpair, the page SDK,deliver: turnvs events to code, and checking it with doctor),whisper-ctranslate2(defaultEnabled: false, the first with aninstall:spec),yt-dlp(defaultEnabled: false, the fetch hop the transcription chain never had — a video page URL is HTML, soweb_fetchandaudio_transcribe({url})both come back empty-handed),instagram(defaultEnabled: false; the first engine skill with a bundled Python runtime —scripts/instagram.pyunderuv runwith PEP 723 inline deps — reading Instagram through its own web API as the brain’s accounts: profiles, posts, hashtag / location / people search. A session is a browsersessionidcookie and is spent as a browser — replaying it through the private mobile API is what gets it invalidated.INSTAGRAM_ACCOUNTSis a JSON list of accounts the script rotates, each with a persisted per-hour budget and request spacing, parked on Instagram’s own throttle signals so the next account takes over; a dead session is re-created by asystem:browseragent that follows the skill’s ownlogin-brief— log in, pull thesessionidcookie withbrowser_run_code_unsafe, store it — so the password never enters the parent agent’s transcript. Sessions and budgets live in$ARTIFACTS_ROOT/skill-state/instagram/(lifecyclestate: never swept, hidden from the artifact browser);uv’s cache is theuv_cachevolume onpipelines, bound RW into the bwrap jail, which is what lets anyuv runskill script work in production) - Mirror + sweep + version stamp — no lockfile; desired state is the carrier, drift is a live hash comparison, retired directories are deleted, and the version lands in
skills/engine/.engine-skills-version - Drift heals — manual edits under
skills/engine/are overwritten on the next sync, and the dashboard refuses to edit them and offersfork - Off-limits to the self-evolving loop —
skills/engine/is on the engine’s denied floor, and a changed brain skill that declares an engine skill’s name (a new shadow, or an edit to an existing override) fails the loop’s diff gate: engine skills are framework content every agent that lists them runs on - Whole-corpus opt-out — a
skills/.engine-skills.overridesentinel (brain-owned, deliberately outside the engine-swept directory) stops the sync entirely - Admission rule — a fact that fits in a tool
descriptionbelongs there; a skill is justified only when it has no tool of its own, is a choice between tools, or has a trigger no tool name matches — and engine skills stay one-file by default
- Shipped today —
-
Skill store CLI —
omnislash skills add | install | update | remove | check | list | enable | disable | fork | measure-size;add/install/update/remove/checkoperate on the brain root and the lockfile only, whilelist/enable/disable/forksee both roots.- The same store as agent tools (since 1.24.0) —
skill_read_file(skill:read: SKILL.md with itsshaand the frontmatter split for reading), and the writes underskill:write(moderate, no approval):skill_write_file(SKILL.md whole, compare-and-swap on itssha; an engine skill or a frontmattername:change is refused),skill_set_enabled,skill_update(re-pull managed skills from their pinned sources — none named = every one),skill_reconcile(restore drifted or missing ones from the lockfile),skill_delete(a managed skill, or a lockfile entry whose directory is gone);skill_installisskill:install(restricted, a person approves) — it brings someone else’s instructions and scripts into every agent that lists the skill. Every write is an action-log row against the skill (or the lockfile, for update and reconcile); the dashboard writes through the same ops. There is noskill_getop:skill_getstays the runtime loader - Lockfile (
skills/.lockfile.json) — resolved 40-char SHA (or"local") plus a filesystem-metadata-independent canonicalintegrityhash; unmanaged directories are invisible to install/update/remove - Canonical sources —
gh:owner/repo[/sub/dir][#ref](native tarball fetch, no git binary),github:alias,git:<url>[::sub/dir][#ref],path:<dir> - Auto-normalized inputs — GitHub web/raw/SSH URLs, GitLab / Bitbucket / Codeberg tree-blob-src URLs,
git+https://,skills.sh/<owner>/<repo>/<skill>and bareowner/repo[/path][#ref]all rewrite into a canonical form; URL-encoded refs round-trip, and the branch-name-with-slashes ambiguity is documented with workarounds - Drift detection —
checkreportsmissingandmodified(includingintegrity: nullfrom a crashedadd) and exits 1 for CI;installreconciles,updatere-resolves the pin viagit ls-remoteand reinstalls only on a moved SHA - Crash recovery — the lockfile entry is written before the directory sync with
integrity: nullas a pending marker, so a mid-flight death is repaired by the nextinstallrather than being mistaken for a hand-curated directory - Safety — 120 s network timeouts,
GH_TOKEN/GITHUB_TOKENfor private repos with embedded tokens redacted from errors, and skill names validated as a single safe path segment
- The same store as agent tools (since 1.24.0) —
-
Per-skill activation is a committed file —
skills/.settings.json({"$schemaVersion": 1, "enabled": {…}}) stores only explicit overrides; a skill with no entry is active per its owndefaultEnabled(omitted =true), so a fresh brain means “everything works”, not “everything is silently off”.- Why a file, not a table — a build has no database, so a DB toggle could never reach the image: the bake derived from whatever sat on disk, and
skills disablenever took the skill’sRUN uv tool installback out. A committed file is readable at build time and run time alike, resolved identically (entry →defaultEnabled→ on) - The bake gates on it —
disablesheds the binaries,enablebrings them in, which is what lets the engine ship heavy opt-in skills nobody pays for until they ask; enabling a skill with binary deps now needs a rebuild, exactly the stateexplainEligibilityalready reported asrebuildableBins - First heavy opt-in corpus skill —
whisper-ctranslate2— local speech-to-text, shippeddefaultEnabled: false: every brain gets the text for free, and only one that writes{"whisper-ctranslate2": true}and rebuilds pays foruv tool install. Before the gate, an engine skill carrying aninstall:spec taxed everyone, so it could not be shipped at all - Activation and Dockerfile move together —
skills enable|disablein the brain repo regenerates the Dockerfile itself; run against prod it says the binaries arrive afteromnislash regen+ commit + deploy, andomnislash doctorreports both drift (.settings.json↔ Dockerfile, with the bin delta) and entries naming a skill that doesn’t exist - Live everywhere at once — every process re-reads the file on each catalog build, so a dashboard toggle reaches bot/worker/mcp/pipelines without a restart
- Keyed by the DECLARED name — the store addresses skills by directory (
skills/<dir>/, also the lockfile key) while identity is the frontmattername; every writer resolves throughSkillProvider.resolveName, since writing the directory name produced an entry nothing reads — the toggle reported success while the catalog kept serving the skill and the image kept baking its binaries - One toggle per name — an override pair (brain + engine) shares an entry, and engine skills get their defaults the same way everyone else does
- Lifecycle invariants —
addwrites no override and drops an orphaned entry so a reinstall starts from the author’s default;removedrops the entry (enabled⊆ installed skills);installnever touches entries, so explicit overrides survive a clone
- Why a file, not a table — a build has no database, so a DB toggle could never reach the image: the bake derived from whatever sat on disk, and
-
Install specs, Docker bake and dependency sizing — a skill declares
install[](brew/apt/node/go/uv, each optionally OS-scoped) and the engine uses it for image bake, doctor hints and size measurement.- Bake targets pinned to
PATH—apt→/usr/bin,uv tool install→/usr/local/binviaENV UV_TOOL_BIN_DIR/UV_TOOL_DIR,npm install -g --ignore-scripts→ the npm prefix; without the pin a uv-installed binary lands in a login-shell-only path and the skill installs successfully yet stays invisible - Verify line makes “baked” checkable — a
command -vloop runs as the runtime user and fails the build, catching package/bin mismatches; the checked list comes fromcollectBakeTargets(bin ↔ the spec that installs it), not fromspec.bins, so ananyBinsskill isn’t over-required - Cache cleared inside the same
RUN— a separaterm -rflayer cannot remove bytes already committed (decisive for uv wheels) - Bake provisions the active set — it reads the same
skills/.settings.jsonthe runtime does, so a disabled skill’s deps stay out of the image and enabling one is a rebuild - Target platform is an explicit argument —
collectBakeTargets(skill, platform)/matchesOs(spec, platform)/appliesToPlatform(meta, platform); the bake passes'linux'and host-scoped callers passprocess.platform, sorequires.osis honoured against the image’s platform and a Mac-built image no longer differs from a CI-built one omnislash skills measure-size— writes{skillsDir}/.deps-sizes.json, re-read bySkillProvider.loadAll()on every call so the dashboard shows fresh numbers without a restartomnislash install— installs missing skill dependencies locally
- Bake targets pinned to
-
Skill resource security —
skill_read_resourceresolves the path, checks containment against the skill’s base directory, rejects symlink escape on every segment, and whitelists only files collected intoresourceNamesat load; frontmatter values that reach a command (install[].package|formula|module,requires.bins) pass closed-world validation (SAFETY_PATTERNS,SAFE_BIN_NAME) that fails the build rather than escaping, because skills are installed from other people’s repos. -
Hot reload and production storage — the content watcher on the skills directory calls
skillProvider.reload()and refreshes exposedomnislash://skill/*MCP resources, so aSKILL.mdedit applies without a restart; in productionskills/is part of the engine G-set baseline, lives in thebrain_statevolume mounted into every service, and is git-synced back to the brain repo. -
Skills vs MCP — the documented boundary — skills are the primary path for new brain integrations and MCP is reserved for baseline and edge cases, because MCP loads every tool schema up front (~700 tokens each, 2–3 servers before accuracy degrades) while a 100-skill catalog costs ~10k tokens with bodies fetched on demand.
- When MCP still wins — complex OAuth with token refresh, real-time subscriptions, shared connection pooling, credential isolation, and browser automation
- What the engine actually ships — engine integration tools stay as a curated baseline, brains extend with
defineIntegration()bundles or with skills; nothing forces a brain to re-implement a popular integration as a skill
Control Plane (Dashboard)
- Dashboard App — auth-gated single-page control plane for the whole brain, served by one Hono process that is also a Docker service (
dashboard), no SSR- Stack — Vite + React 19 + TanStack Router (file-based) + TanStack Query + tRPC v11 + Tailwind v4 + shadcn/ui; specialised layers: CodeMirror 6, xterm.js, React Three Fiber,
@xyflow/react, Shiki, TanStack Virtual - Keyboard registry (
useKeys) — every shortcut is one scoped binding (global/list/detail/dialog), two-stroke sequences included:gthen a nav item’s letter opens it (g hHome,g iInbox,g rRuns) - Display zone — dates render in the brain’s
locale.timezonewith the zone’s short name when it is not the device’s;?tz=utc|localoverrides it for the tab - Brain-branded chrome — sidebar logo and browser favicon are served from the running brain’s
assets/logo.*/assets/favicon.*at/logoand/favicon.ico, with a logo→favicon→engine-placeholder fallback chain; the same favicon chain serves the published-artifacts origin, so a shared link carries your mark in the tab; brain name comes from theconfig.getop (part: 'project') - Attachment upload/serve —
POST /upload(25 MB cap, sanitised filename) persists into the content-addressed blob store and returns ablobRef;GET /attachment?ref=streams it back throughBlobStorageRouter.retrieve()with a path-escape guard - Own health surface —
/health,/ready,/metricson the dashboard’s own port; registers itself in the RedisServiceRegistrywith heartbeats - Statics survive a deploy — the document is
no-cachewhichever handler produced it, hashed/assets/*areimmutableon 2xx only, a missing chunk 404s rather than gettingindex.htmlback, and a client-sidevite:preloadErrortriggers one guarded reload behind a Suspense error boundary - Access control — Caddy
basic_authonapp.{PUBLIC_DOMAIN}in production domain mode,127.0.0.1-only port binding instartmode; there is no per-user identity inside the dashboard yet, and dashboard-originated writes attribute to thedashboardActor kind
- Stack — Vite + React 19 + TanStack Router (file-based) + TanStack Query + tRPC v11 + Tailwind v4 + shadcn/ui; specialised layers: CodeMirror 6, xterm.js, React Three Fiber,
- tRPC API — engine ops plus the dashboard’s own routers — one typed end-to-end contract; UI types are derived from
RouterOutput, never redeclared- Engine ops, bound by name — every answer or command an agent also needs is an engine op the dashboard binds in one line (
feature.list,scenario.update,health.list,blueprint.write, …): a page reads and writes through the same op, capability check and dry run an agent’s tool does, never logic of its own. Runs, the Inbox, sessions and spend are ops whole:run.*,hitl.*,boss.*,eval.*,watch.*,usage.*,budget.*,audit.listand everyagent.*read and command — theagentrouter adds only the live output streams - Execution —
planning,loop,input(the dashboard’s own routers, beside the ops) - Agents —
agent,brain,brief,scheduler,skills,memory,editorChat - Governance —
capability,grant - System —
system,tool,integration,connection,catalog,webhook,activity,artifact,terminal,git,changes,atlas - Transport split — tRPC
splitLink: subscriptions overhttpSubscriptionLink(SSE), everything else overhttpBatchStreamLink— batched per event-loop tick, so a page mounting 30 queries opens one request and an SSE-driven invalidation of two keys sends two ops; results stream back as each resolves
- Engine ops, bound by name — every answer or command an agent also needs is an engine op the dashboard binds in one line (
- Dashboard chat — the dashboard’s one conversation is the chief’s DM with the primary owner: the same chat agent the chief’s bot serves in Telegram, so a message typed in either place is one thread (
chat.get/chat.send, decision 0038)chat.get(runtime:read) — the chief’s persona, its DM chat agent, the bot’s Telegram username, whether the chat is bound in Telegram (the owner has written to the bot once — until then answers stay in the dashboard), andunavailablewith the reason when the brain has no such chat (no bot runs the chief’s role, no primary owner, no token); never creates the rowchat.send(chat:send) — the caller’s message as a user turn of that chat (queued behind a turn in progress, orinterrupt), created on the first message; the turn belongs to no single surface, so the mirror carries the message and the chief’s answer to the bound Telegram DM. Attachments are the dashboard upload’slocal://refs, checked by the same ingress check as every chat composer (@omnislash-ai/config/chat-attachments). A slash command or/compactis not a message — it goes through the agent’s own routes onchat.get’s agent- Refs, never content — what the message is about rides as typed refs (
{ kind, id },@omnislash-ai/types/ref): a<refs>block at the end of the text, one line per ref naming the tool call that reads it (- run:run_x — read with run_get({"id":"run_x"})), so the chief reads the object through its own grant and any chief role understands it with no prompt edit. A view is a ref too (query:<query op>?<input>): the chief re-runs the list the person is looking at; only a query op may be named, with an input that op takes — a view ref naming a command, or one the op could not re-run, is refused, and so is a text that already ends in a<refs>block of its own (only the engine writes one). A chief role lacking the capability behind a ref’s reader tool sees the ref but cannot open it - Telegram mirror — a dashboard message reaches Telegram without its
[Context: Dashboard · …]line and with its refs as one line each: a link to the dashboard’s/go/<ref>whenDASHBOARD_PUBLIC_URLis set, the ref as code otherwise chat:send— a capability of its own (moderate, HITL): every op is also an agent and MCP tool, andchat_sendputs a user turn in the owner’s DM — every role granted'*'holds it, so an agent’s or MCP client’s send waits for a person (the dashboard dock does not), and the message names who sent it; a brain drops the gate with"hitl": falseinconstitution/engine-capabilities.json
- Permanent object links —
/go/<kind>:<id>— a message the engine writes that names one object links its permanent dashboard address, which the dashboard resolves to the object’s page of the day, so the link outlives page moves:dashboardRefUrl(ref)(@omnislash-ai/config/dashboard-link,nullwithoutDASHBOARD_PUBLIC_URL; the path alone isrefGoPath,@omnislash-ai/types/ref) — use it from brain code too. The rating card links a workflow’s or a pipeline’s run as/go/run:<id>and a heartbeat tick as its agent’s/go/session:<agentId>; the Telegram mirror links a dashboard message’s refs the same way;loop_statuslinks each entity as/go/scenario:<id>//go/heartbeat:<id>//go/role:<id>(its page under its feature, whose header’s loop chip — Evolving while the loop is on for it, a quiet Loop off → while it is off — opens the loop view). A link to a view of one object carries the view’s one param, which/goforwards by a closed table (any other param, and these on any other kind, are dropped): the decide card’s Full proposal links/go/cycle:<id>?proposal=<round>(the retry card the bare/go/cycle:<id>), a chat episode’s rating card/go/role:<role>?episode=<runId>—dashboardSubViewUrl(view)/subViewGoPathbeside the two above. A view rather than an object is aqueryref:loop_policy’s link for the brain tier is/go/query:loop.policy(an entity’s is the entity’s own/go/<kind>:<id>) - Page map — dashboard.md: every page by navigation item and the owner’s questions each answers, generated from the dashboard’s route tree, plus how an agent links the owner to an object (
/go/<kind>:<id>on the dashboard URLendpoint_listreports) and each ref kind’s id - Live by topic, one stream per tab — a page stays fresh by naming what it shows, not by polling or per-widget sockets
changes.onTopics— one server-sent-event stream per tab over the topics of the queries on screen (a kind-wide word such asrunsorinbox, or one object as<kind>:<id>); a fired topic refetches only the queries that named it, at most every 2 s for a kind-wide topic and every 250 ms for one object, and a refetch never cancels a fetch in flight- Connection signal — the same stream drives the connection banner and pauses queries while the tab is offline; a stream that comes back after a drop refetches every query on screen once
- Writes say what they dirty — a write names the queries it changes and stays pending until they have refetched, so a Save never shows the old value
- Per-page exceptions —
agent.onOutput(agentId)for a live conversation,agent.onOutputManyfor side-by-side sessions andactivity.onEventfor System › Event stream are the only subscriptions a page holds - Resumable subscriptions — SSE frames are yielded via tRPC
tracked(id, data), so a dropped connection resumes from the last id (used byterminal.onDataand the agent streams) POLLtiers — what no topic announces polls on one constant set (live5s /normal30s /minute/slow5min) that every such query reads
- Home (
/) — the operation at a glance in nine sections, read top to bottom by what you need from it; every read is an engine op, so Home says what an agent askinghealth_list,run_summary,hitl_list,loop_statusorbrief_statushears. Each section is a soft card; each part opens one level down in place (an expanded row, a popover on a desktop, a sheet on a phone) and from there to its own page; under the working row a section folds behind one line on a phone (the brief, the Ask box and Recent activity never fold)- The hero — the brain’s name and clock, the status group (service health with every probe, the doctor’s errors and warnings, brain sync with Sync now; one chip on a phone), one sentence answering “does anything need me?” (the pending requests — counted whole, as the Inbox badge counts them — plus the self-evolving gates owed with no request of their own), the day’s figures in large type (executions, success rate, spend, “+N new since 14:05”), and the pulse: 24 hourly columns of every execution by outcome with spike marks and the next six hours’ fires
- Needs you │ Live now │ Coming up — Needs you: one ranked list — requests answerable in place, self-evolving gates owed, failing entities from
health_listwith their last failed runs, entity checks, health probes and services, an agent over its context budget, a grant lapsing within the hour — each problem once. Live now: Blueprint and Pipeline runs, evaluations, planning sessions and standalone working sessions in flight, waiting-on-you first; a row opens onto its tasks / steps, with Watch live / Watch planner, Cancel planning and the request a plan waits on. Coming up: the schedule ahead (engine passes included) with Run now where an op runs it out of schedule - Evolution — the self-evolving band (see self-evolving.md): the fleet in one sentence, cards for the entities that need you, are stuck or are mid-cycle (each with its objective’s trace), the waiting entities with how close their next analysis is, and how the brain changed itself — the newest cycles, kept, reverted or turned down, each opening onto its hypothesis and the evidence it was judged on
- Operation — two tabs, the one you picked remembered: Features, every feature on the Features index’s map (each a lego plate of its parts at one stud pitch, its health, what needs you); Map, the brain as business areas over its layers, one tile per kind group with its ledger (next scenario fire, failing scenarios, heartbeat and hook outcomes today, ingest events and errors today, skills missing dependencies), leading to the Features index,
/agentsor/atlas - Business — the declared KPIs (
kpi_list), a cell each, opening its full line - The chief, by its persona’s name — Daily brief: today’s brief as the chief wrote it into its DM with the primary owner (see § Scheduler & Heartbeats › Daily brief), “writing” while one is on its way, Send now / Send again, Open conversation, and Brief settings — when it is written and what the chief is asked to write (MANIFEST
brief; an empty note returns to the engine’s default). Ask<persona>: a door into the chat dock with three starters drawn from what is on screen - Recent activity — the day’s traces, filterable by kind and failures, each opening onto the path it took; the loop’s grades and cycle steps fold into a line each
- Vitals over
?window=24h|7d|30d, each against the window before: spend (billed vs subscription, the month’s pace), quality (the pass rate of the window’s graded runs — workflow, pipeline and heartbeat), agents (turns, failures, the fleet by state) - Context budget beside Guardrails (the tools by risk tier, the grants in force, ending first or most used)
- Brain Atlas (
/atlas, reached from Home’s operation map;?focus=<atlas id>flies to one entity,?window=7dreaches a week back) — one 3D map of the whole brain and the last 24 hours of its work: the first glance is 5–8 top clusters with readable labels and counts (size = member count), linked by aggregated traffic; drill in to reach every entity, dormant ones included; click opens the entity’s page- Engine read model —
@omnislash-ai/atlas: one collector per kind group into flat nodes pluscontains/runs-as/uses/readsedges; a failing collector marks only its grouperror, and everyattention/errorcarries its reason - Timeline read model —
atlas.timeline({ from, to })draws every run in the window from the one runs table (executions: workflow and pipeline runs, heartbeat ticks, hook fires — skipped ones included —, grades, each step of an improvement cycle) in the run vocabulary (a paused or retrying run is still open), plus the chat turns no run claimed, HITL waits and ingests — no extra event log; a run holds the agents it dispatched (a tick’s agent, a run’s task agents, a cycle step’s proposer or implementer) and every helper they spawned (every agent records its creator), and a chat reply shows the helpers it spawned; each trace belongs to its feature through its entity (the scenario, heartbeat, hook or role it ran as); up to 7 days where the rows still exist (a reaped tick keeps its run, entry only; a conversation compaction folds that agent’s earlier turns out of the timeline) - Reads —
atlas.structure(refreshed when the brain, a run or the loop moves, a slow poll beside),atlas.timeline(refreshed when a run, a request or a session moves),atlas.facets({ id })for the entity card; the client-safe@omnislash-ai/atlas/viewturns both into clusters, per-cursor stats and search - Four view settings, persisted per browser — clusters (engine layers · business areas by brain feature — labelled by the
nameitsFEATURE.mddeclares, else the humanised directory name — then Shared platform for what three or more features use and a dim Unused area for what nothing touched), look (nested bubbles · star systems · stacked floors), connections (always-on traffic tubes · on focus only), drill-in (fly inside · expand in place · split view) - Links and attention — link width = traffic in the trailing 60 min of the cursor, red share = failures; traffic the config does not declare (a relation the brain declares elsewhere — a scenario’s
runs-asrole, a role’sreadsmemory line, ausesskill — but not for this pair) draws dashed; a failing cluster gets a thick red ring (a thin one for isolated failures), one waiting on a human an amber one; the failed / waiting counts open the entities behind them, one click from the overview - Timeline — stacked histogram (ok · failed · waiting) plus a raster row per top cluster; drag or click to seek, Live follows now (the default; pause freezes the cursor), replay at 1× / 60× / 600× / 3600×, range 24 h or 7 d, keyboard stepping
- Search — every entity, dormant included, with what is failing or waiting on it now; a hit flies to it and selects it
- Engine read model —
- Unified Activity Stream (System › Event stream,
/system/events) — single firehose across agents, workflows, pipelines, HITL, hooks, scheduler and queues, with live updates, search, virtualized rendering and system-event filtering- Reads the engine’s event tail — every
EventBus.emitalso appends to one capped Redis Stream (@omnislash-ai/events/tail, ~20 000 entries); theevent.tailop pages it (readEventTail, also the agent toolevent_tail) andactivity.onEventfollows it live (followEventTail— one follow per dashboard process, fanned out to every tab), so history and live share stream ids and a new tab catches up without a dashboard-side buffer - System/user split — events whose Zod schema carries
.meta({ category: 'system' })are hidden by default - Shared reader —
readEventTail(redis, { before, since, events, limit, scan })is the single “recent events” read; feature panels (the Providers page’s provider feed, the scheduler’s activity feed) filter it instead of keeping a buffer of their own. A filtered read examines at mostscanentries (default 2 000) and returnsnextto continue, so a feed of rare events polled every few seconds does not walk the whole tail; both feeds page on it (nextCursor, a Load older button), so a match further back in the tail is a page away rather than missing
- Reads the engine’s event tail — every
- Live Log Explorer (System › Logs,
/system/logs) — Loki-backed log tail inside the dashboard, no Grafana round-trip; a run’s “Logs of this” opens it filtered to the run- LogQL builder — server-side
buildLogQLover indexed labels (compose_service,level) and structured metadata (runId,agentId,pipelineRunId,taskRunId), with regex- and string-escaped free-text search - Three-state Follow —
off(manual),live(polls atPOLL.live, pins to bottom),paused(keeps counting “+N new” while rendering a frozen snapshot so the line under the reader never moves) - Rendering — virtualized rows with
measureElement,keepPreviousDataso the tail never flashes a skeleton between window slides, click-to-expand full JSON + filter-by-ID chips, Wrap and Local/UTC toggles, per-row copy - Degrades honestly — Loki and Alloy are prod-only, so in
omnislash devthe page short-circuits to a notice instead of querying an unreachable backend
- LogQL builder — server-side
- Runs (
/runs,/runs/:id) — every run of every kind (workflow and pipeline runs, heartbeat ticks, hook fires, evals, cycle steps) on the ops an agent reads too (run_list,run_summary,run_get)- List — the top-level jobs, ticks and fires (
run_listtopLevel: a job a tick’s agent started stands beside its tick; evals and cycle steps one Filters → Kind away): Now — plans being made and runs still going or waiting on you, whenever they started — over the days the rest started on, each day’s quiet ticks and fires folded into one stretch row per entity (fold: "routine"; a filtered list lists runs one by one). One row grammar: state word + tone + glyph, the entity’s figure and the run’s name, its progress or what went wrong, when and how long (“2.3× usual” when slow), cost; Pause / Cancel or Retry on hover, the rest in ⋯. A figure line (running · need you · failed · in all, each its status filter), search, Filters (kind, status, entity, feature, period), live rows; “Ask about this view” hands the filtered list to the chief as aquery:ref - View — List · Entities (one row per entity: its newest runs, “n of m OK”, live, spend, last failure) · Timeline (
run_timeline: a lane per entity, Late / Down, the next fires up to 7 days ahead, the scheduled messages and what listens), remembered per browser; Group by cause folds the failures by what went wrong, each cause with “Retry / Reset all N” (a pipeline scenario’s ⋯ “Reset runs broken by this edit” opens its given-up runs this way); optional Eval, Loop and Model columns. A feature’s page draws its runs on its own time axis - Detail — any run id opens here (
/go/<id>too): status in words, actions (Re-run with edited input, Compare with previous, Evaluate now, War room among them), facts (priority, dry-run replay, PR, requests, issues, outcome, real vs subscription cost; a pipeline’s invoked-by, deployment, trace id and Open in Restate; a tick’s or fire’s agent, model, usage, error with its stack, failed tool calls and result), the sessions and entity it touched; the rail beside it lists what the list listed,j/kand Esc walk the list it was opened from,p/oopen the run it ran inside / where it came from - Steps · Timeline · Log · Raw (
?view=) — requests answered in place, the answered ones collapsed; a workflow run’s tasks grouped by phase, a pipeline’s steps, each with description, definition of done, result or output, produced files, cost and its agent (opened over the page,?session=); a retried task or step nests its attempts with a switcher; what the run’s agents watch (Delete watch). Timeline is the run’s trace on a Gantt — a bar per attempt, waits and back-offs hatched, turns over the bar that billed them. Log is one feed of every agent’s work plus the pipeline’s events and Restate’s journal, oldest to newest, live with a legend, Pause, Load older and search. Raw is the wholerun_getanswer with Export JSON - Tabs (hidden when empty) — Usage (twelve sections of the run’s spend), Evaluation (the grade, live progress, Evaluate now, and the cycle the run opened: its phases, PR or revert, verdict per metric), Files (the deliverable and every task’s files, previewed and downloaded at any size), Sessions (the run’s agents as a list, or side by side — the war room); Compare opens in a sheet: deltas, the input diff, spans aligned, the brain sha each ran on
- List — the top-level jobs, ticks and fires (
- Sessions (
/agents/sessions, one agent’s with?agent=) — where the Agents item lands: every agent’s thread of sessions on the ops an agent reads too (agent_list,agent_summary,agent_get); a click opens a session live beside the list; the layout, Show deleted and the sort are remembered per browser- One row per agent’s thread — a lineage (
groupKey: a chat, a heartbeat, a named agent), represented by the Boss session, else a live one, else the most recently touched (under a status filter, by its session that says the word); aligned columns (status with Stuck explained on hover, session and its marks, agent, from, runs, model, spend, active); its Runs cell (the newest five by severity and the count) opens every past session — open one live, Pin it, Open all; live sessions and the Boss lead every sort, Now over Earlier, rows take their place live; a spawned session sits under its parent (created_by) - Opens live — a click opens the session’s conversation and composer beside the list (
?open=), replacing the open one; Pin keeps others beside it (?pinned=) — ten panels at most, the open one’s place always left, so a click never drops a kept one; Follow (View,?follow=1) keeps each session that starts running among what the filters list, in a free place or a finished one’s — the war room follows a run the same way; Esc closes the panel holding the keyboard, the last closed brings the full list back; the list beside the panels collapses ([) and slides out from the left edge - Head and controls — counts now (running, stuck, need you, failed, in all) — exactly what each word lists under the filters in force (
agent_list’scounts) —, each its filter; New session and Run Boss; one row of controls: search, Filters (status, source, agent, period — Any time, Today, Yesterday, Range —, held, sort) with the filters in force as chips, View (List, Live grid, Cards, Show deleted, columns) and “Ask about this view” as aquery:agent.listref - Cards — what it is doing now, the request waiting on you named in one line (its panel answers it), its run’s progress, what it cost
- Removed agents — a role no feature ships that a session still names keeps a page under
enginewhose content is its sessions, and an Engine Contents group
- One row per agent’s thread — a lineage (
- Session page (
/features/:feature/agents/:agent/sessions/:id) — one session as a conversation: a full-height workspace under its agent and the feature that ships it (enginefor asystem:role or one no feature ships; link one session as/go/session:<id>, which redirects there; a wrong feature or agent in the URL redirects to the right place, the tab kept), on the ops an agent reads too (agent_get,agent_history,agent_prompt,grant_list,run_list)- One thin bar (the shell’s top bar on a phone) — its status dot and name (rename in place), a switcher to the other running sessions (⇧S), the status word (Stuck included) with its reason on hover, who holds it when intercepted, a deleted session’s reap countdown (
agent_get’sreapAt); its facts — the agent, the run that started it with its status, what it spent, the lineage strip, and counts that open its grants (grant_list({ agentId }), “All grants”), what is scheduled to it and what it watches (Cancel, Delete), the runs it started (run_list({ startedBy })) and its earlier conversations; Thread · Timeline · Raw; everything else in ⋯ (Stop, Edit session settings with the widen step, Context, Rename, Pin to matrix, Align, Close episode, Clear, Copy CLI session ID, Export —agent_export—, Delete). No shell notices over a conversation - Thread · Timeline · Raw (
?view=) — the thread: messages with images, Copy and Copy full prompt (agent_prompt, the two stored halves re-joined byte-identically), a “system prompt changed” marker with the diff against the turn before, the files a turn changed, per-turn usage on hover, tool calls grouped and expanding to Command / Input / Output (verbatim, without the HITL preview’s PII shield; a collapsed row previews the call — the shell command, a workspace-relative path, else compact-JSON arguments), reasoning folded, proposed-plan cards, each request answerable where it was asked, “New conversation” where a clear started one; Timeline: the context-fill line with compaction marks and the tool-call swimlane; Raw:agent_getwhole - Composer — the session composer below (see Agent Composer), with
@refs: one plate with its icons inside — attach, model,/, ”?”, the context ring (its fill, Compact, the Context page), Interrupt, Send; the queue and the interception line with Release attached; on a run’s task agent the plate holds while its run lives, with Intercept right there - Working — a turn in flight reads as a seeded verb with its time (“Burning cycles for 3m 2s”)
- Context (
…/context) — the prompt its newest turn ran with and what moved against what its agent’s definition composes now (agent_prompt({ against: 'definition' })) - Archived conversations (
…/conversations/:conversation) — one conversation a compaction or a clear ended, read-only; where the composer would be, Ask<persona>about this conversation opens the dock with the conversation as a ref the chief reads (agent_history), and the dock’s starters offer “What was hard here?” and a summary - Peek — every session chip opens the session over the page on ⌥-click or a long press (a side sheet, a bottom sheet on a phone): the same thin bar, its live transcript and composer plate (Intercept and Release in it), ↗ “Open session”
- One thin bar (the shell’s top bar on a phone) — its status dot and name (rename in place), a switcher to the other running sessions (⇧S), the status word (Stuck included) with its reason on hover, who holds it when intercepted, a deleted session’s reap countdown (
- Edit session settings — in the session bar’s ⋯: every field the engine lets a session change — names, lineage, agent, model, where it runs, repo, extra capabilities, extra instructions — prefilled, with the pickers the New session dialog uses (
RolePicker,ModelPicker,RepoPicker,CapabilitiesPicker, therunInrung); a change that widens what the session may do shows the widen step- One mutation, one validator — the form, the inline rename and the composer’s model picker (and its
/model <ref>) all go throughagent.update/agent.widen, whose input IS the engine’sAgentPatchSchemaand whose validation ISresolveAgentPatch; the chat bot’s ✏️ Edit and theagent_updatetool land on the same applier, so no surface can accept a value another refuses - Only deltas travel — an untouched field is absent from the patch and an emptied nullable one is an explicit clear, so opening the dialog rewrites nothing and the “what changed” answer stays honest
- One mutation, one validator — the form, the inline rename and the composer’s model picker (and its
- Agent Composer — one session composer for every host: the session page, a panel (the Sessions page’s, a run’s war room’s), a mini matrix cell, the session peek
- Inline slash commands — one registry for the session composer and the chat dock: a ranked command menu the field drives, split into dashboard commands and CLI-harness passthrough commands filtered by the agent’s detected runtime (
claude/codex/opencode/api), plus help and status dialogs./model <ref>switches the agent’s model through the picker’s ownagent.updatewrite (a bad ref meets the same refusal); a bare/modelopens the picker (a host without one leaves/modelin the field for a ref) - Harness passthrough that reaches the harness as a command — a passthrough (
/review,/init, …) goes throughagent.send_command, notagent.send: a CLI harness reads a slash command only when it leads the user message, so the command is its own inbox entry (kind: 'command'), served alone with no envelope in front of it @mentions — the chat dock’s menu in every session composer but a mini cell:@and a name search the brain (brain.search), a pick puts the name in the text and the object among the draft’s ref chips, sent as the message’srefs(agent.sendwrites them as its<refs>block — the agent reads each through its own tools)- Pending sends — a message sent to an idle session shows at the end of its transcript until the real one lands; one sent to a busy session waits in the mailbox rail (Send now, Clear, remove one)
- Model picker + attachments + draft persistence — the footer’s model picker (
agent.update, a widening model behind the widen step), files picked, pasted or dropped through/upload(10 a message, 25 MB each), the draft kept per session in this browser - Fake-directive popover — one-click
<fake>{…}</fake>templates (kitchen-sink, per-step, failure presets, fixtures) for driving thefaketest harness from the UI - War-room gating — inside a workflow/pipeline war room the composer is held while the run still owns the agent (run not terminal, or the agent still mid-turn) and shows a “Chat is off” notice; once the run lets go, a “Continue chat” button reveals the ordinary composer. Interception is the door in while the run lives: an intercepted session skips the hold — its composer talks to the agent (
agent.send, the one send the ownership gate lets through) under an “Intercepted” bar whose Release hands it back (agent.release)
- Inline slash commands — one registry for the session composer and the chat dock: a ranked command menu the field drives, split into dashboard commands and CLI-harness passthrough commands filtered by the agent’s detected runtime (
- Boss mode — started from the Inbox header or the Sessions page’s head with the full per-run form, prefilled from the brain’s defaults (
boss_status/boss_start, Save as defaults =manifest_update { boss }); while it runs a shell notice above every page says who answers for you, until when, and Stop with a confirm (boss_stop) - Interception — take the wheel of a live workflow agent: Intercept on the session (
agent_intercept) pauses its run; the session’s composer talks to it under an “Intercepted” bar (agent_send— an active interception exempts it from the ownership gate) whose Release hands it back (agent_release); while a session is held a shell notice above every page names it, who holds it and that its run waits, with Open and Release (agent_summary’sintercepted,agent_list({ intercepted: true })) - Scheduler (System › Schedule,
/system/scheduler) — unified view of cron-scheduled scenarios and heartbeats: a fire-time timeline of what will actually fire (empty while the scheduler brake is on), scheduled-scenario rows (cron, next fire, last run of either engine, last result, loop chip, grant status), heartbeat rows — switched-off ones included — with a server-computed next fire and the same loop chip, queue stats, reconciler status, force-reconcile, and recent-activity listing - Inbox (
/inbox,/inbox/$request) — every request owed to a person overhitl_list/hitl_get/hitl_summary, as one list: Need you (waiting longest first; closing soonest or riskiest on choice), two or more from one run folded into the run’s stack (Approve all N), over the days of what was handled — each closed request in the engine’s words (outcome: “Approved 2 of 3”, the card’s own action, “Expired”, “Not delivered”) with who decided it. A figure line (need you · close today · not delivered · handled, each a filter; how long answers take and who gives them on hover), Filters (status, kind, who asked, agent, answered by, capability, sort, period) with counts; one primary per row on hover, every verdict in ⋯, selection and bulk respond on use (onlybulkablerows). A request opens beside the list (j/kwalk, Esc returns): what it asks at reading width — a grant’s scope and life (grantTerms), its capabilities with their risk, the tool calls it tried —, the answer bar pinned under it (a low-risk verdict behind an undo window, a risky one confirmed, then-next), its facts, links and delivery;view=raw. Boss mode on the tab row (boss_status/boss_start/manifest_update { boss })- PII shield — tool-call argument previews are redacted before render: sensitive key names and token-shaped values become
«redacted», long strings truncate
- PII shield — tool-call argument previews are redacted before render: sensitive key names and token-shaped values become
- Features (
/features, since 1.23.0; views since 1.24.0) — every feature ordered by itsweightfromfeature_summary(what it is made of, in units: a scenario 3, a hook 2, a heartbeat, an agent or a registering code module 1), in the view you pick (remembered per browser; every brain opens on the map): the map (every feature a lego plate exactly as big as its build — every plate at one stud pitch picked from the screen’s width, so the same figure is the same size everywhere —, packed in lines heaviest first, each tile only the feature’s icon, name and “N need you”, its lego figures on a baseplate, and a foot row of its health (a feature in trouble says why when you hover or tap the word) beside its week’s compact bars: a scenario the flat stairs, a pipeline the waypoints, a hook the dish, a heartbeat the raised heart, an agent the minifig head, a code module that registers something (a tool, an event source, an integration, a bot policy, lifecycle hooks — never a helper file) the</>tile, one legend of them above the map; a figure is red when failing, ringed in the brand colour while running, banded amber when someone owes an answer, and a figure the self-evolving loop evolves wears a pearl finish — a soft sheen with a slow band of light passing over it; hovering a figure says what it is, its state, last run and next fire, a click opens it; the week’s bars open its week in figures and a bigger chart) or the list (one sortable row per feature, its lower-ranked columns giving way on a narrow screen). Every view says only feature-level facts: its own icon (on the name’s line; none when it has none), name and purpose (on the map, the name’s tooltip), one health word with its glyph (OK · Degraded · Failing · Idle · Off — engineattentionreads Degraded only when something is in trouble beyond a person owing an answer —health_list’sonlyNeedsYou), “1 needs you” / “N need you” (a raised hand) at the right of its title only when someone owes an answer, in the list a running mark only while something of it runs, its last seven days as a soft mini chart (failures tinted) that opens, on a click, one bigger chart of its runs, failures and chat turns over 24h · 7d · 30d (feature_trend), the loop’s ∞ when the self-evolving loop is on for any of its entities (its facts on hover: how many evolve, what the loop does now, what waits on you, the last outcome, who answers its gates), what it ships by kind and, in the list, its week’s spend and share; a feature opens its page, and its ⋯ menu (over the tile’s corner on hover or focus, always on touch) holds the switch (its confirm names what stops in other features, its dry run the live runs it kills), the icon picker and the README. The header says how many features, how many fail and “N need you →” to the Inbox; the built-in Engine is a small tile at the end, beside “Across features” (every scenario, heartbeat or hook across the features, with a filter) and the brain-wide settings (MANIFEST’s health windows and hook defaults throughmanifest_get/manifest_update). Runs, lanes and entity rows live on each feature’s own page- Feature page (
/features/<feature>) — one page, no tabs: its header (health with the cause, what runs now, the loop, “N need you”, the switch, Answer or Run now, ⋯); an activity band with its lego figures in a slot (each brick opens its entity) beside its runs on one span — 24h · 7d · 30d, the switch at the right — with the figure line, failures at the baseline, the brain’s commits to it as ◆ and the next fires; the requests and evolution gates waiting on you, answered in place; its entities as one dense list grouped by kind — each row its brick, state, a mini chart over the span, runs and how they ended, last run, next fire (an agent’s model, a hook’s event), spend, switch and ⋯; then what its agents scheduled themselves (Cancel / Delete), its README, what it uses and its code (the Engine adds its shared instructions and passes). A click on a brick, a row or a mark opens that thing’s own page; every figure comes from the same ops the agent tools answer
- Feature page (
- Entity pages (
/features/<feature>/scenarios|heartbeats|hooks/<id>, since 1.23.0) — one scenario, heartbeat or hook as a page under the feature that ships it (a wrong feature in the URL redirects to the right one;/go/scenario:<id>,/go/heartbeat:<id>,/go/hook:<id>land here), in three tabs- Header — its health in words with the first reason, its trigger and next fire, “held · feature off” when its feature or the scheduler brake holds it, its own switch (the entity’s own
enabled), the loop’s chip (Evolving, or Loop off — a link to the loop view either way), and its actions: Run, Run with input…, Run now, Test-fire (a hook, on its event’s sample), Plan preview, Reconcile, Lock / Unlock, Show last fire (a hook), Delete (a dry run first: what it kills and removes), Delete blueprint (the plan alone —blueprint_delete), Ask why, Edit file directly - Overview — Health, the requests about it waiting on you (answered in place), runs now and its last ten outcomes, its standing grants, its blueprint in one line, its inline hooks, the loop’s sentence, its History (actions, file commits, cycles)
- Definition — every field a typed change over
scenario_update/heartbeat_update/hook_update: a draft, the op’s dry run as the preview, the commit on the file’s sha, and a change that widens what its runs may do takes the widen step (*_widen); the prompt in the editor popup; a scenario’s plan as a read board (?view=blueprint, Edit YAML, Open blueprint editor) and its other owned files (?file=);evaluation:andloop:are not here — the loop’s Policy view writes them, resolving every write the way the loop will and keeping your comments; Edit file directly opens the raw file (*_write_file) with the merge view on a conflict - Runs — its runs, one opened in place (
?open=<run>): a tick’s or a fire’s agent turn (agent, model, usage, error, failed tools, result); a file that does not load shows the loader’s reason and the file instead, and nothing starts it until it loads
- Header — its health in words with the first reason, its trigger and next fire, “held · feature off” when its feature or the scheduler brake holds it, its own switch (the entity’s own
- Agents (
/agents) and agent pages (/features/<feature>/agents/<agent>, since 1.23.0) — every agent definition of every feature, the engine’s own included, and one page per agent under its feature (a wrong feature in the URL redirects to the right one;/go/role:<id>lands here)- Index — the Agents item’s second tab: feature, source (brain / engine / customised), model, context before turn 1, sessions, cost, last active; sorted by last active by default (working agents first, the sort remembered per browser); search over name, id, description, model, skills and capabilities; New agent; Defaults for every agent behind the item bar’s ⚙ (MANIFEST
context,promptCache,promptBudget,state,delegation) - Agent page — one page, its feature page zoomed in: its figure in its live state, its health with the cause, Answer or Start a session; its sessions on the span as a band (
agent_summarywithrole), what waits on you about it answered in place, its problems with their fixes, its newest sessions, where it works (what dispatches to it, the chats it answers), its history, its definition read; a removed agent’s page is its sessions - Definition (one level under it) — name, description, model, memory, skills, capabilities typed; instructions with the Prompt coach; Edit file directly
- Context (one level under it) — footprint, prompt modules, Trim to used
- Index — the Agents item’s second tab: feature, source (brain / engine / customised), model, context before turn 1, sessions, cost, last active; sorted by last active by default (working agents first, the sort remembered per browser); search over name, id, description, model, skills and capabilities; New agent; Defaults for every agent behind the item bar’s ⚙ (MANIFEST
- Catalogue pages — the brain’s catalogues, each under the item it belongs to, as they were built before the feature pages
- Skills (Knowledge › Skills,
/knowledge/skills) — catalog with source (engine/brain), scripts presence, capabilities,SKILL.mdinline editing, enable/disable, add/remove, install - Memory (Knowledge › Memory,
/knowledge) — per-line two-pane explorer: a tree rail (priority pinned, dates newest-first, single-child chains compacted) beside the rendered file, with line/selection/query in URL params; Edit opens the shared full-screen editor popup,memory.searchgreps paths and bodies uncapped, and the page is read + edit only - Models (Agents › Models,
/agents/models) — drag-and-drop ordering of role → model preference, backed bymodels.json - Bots —
/chats(the Settings block; tabs Bots · Identities · History): who may use which bot where. A header of bot health fromgetMe()(privacy mode, can join groups, admin in N of M groups), a rail of every bot’s DM, default and groups with who may talk in words, plus Seen, not served (groups a bot sits in that no rule serves, each with Serve); per chat the tabs Rule (the rule as a form, every field labelled with the tier it comes from — engine default / bot default / this chat / code — and the role’s capabilities and memory reach shown before saving), Identities (who is in the chat from its log plusidentities, tier inline-editable), Simulate (the real resolver and the brain’s realpolicyfunctions on a typed message, with a status stub, rendered as the trace) and Raw (the YAML of that one tier). Identities (/chats/identities) lists everyone the brain names — the env owners andidentities— with the tier that holds in every chat and where it is set; History (/chats/history) isaccess.yaml’s commits. The page binds theaccess.*ops: a save goes throughaccess_update, and one that would widen access is listed and written throughaccess_widenonly on the person’s yes — no grant card, the dashboard is the owner. Ask<persona>about this chat opens the chat dock with the bot and chat refs; the chief reads them withaccess_overview— chat-access.md - Eval methods (Evolution › Methods,
/evolution/methods) — brain-defined evaluation methods with an inline prompt + criteria editor
- Skills (Knowledge › Skills,
- Typed forms over the ops — every entity and agent field is a form on the op’s own input: the pickers (model, role, event, skills, repo, capabilities, planner method) list what the engine resolves, the schedule and input-schema editors write the typed value, the dry run’s issues land on their fields, so nothing is authored by hand-editing YAML; a run’s or a request’s input is a form generated from its JSON Schema
- Blueprint editor (
/features/<feature>/scenarios/<scenario>/blueprint) — a full-window board under its scenario: a rail of every scenario’s plan across features (blueprint.list,?q=), the config rail (Plan · Execution · Output · Inputs · Source,?rail=), the phases and tasks with drag-and-drop (phases reordered, tasks moved within and across phases by pointer or keyboard, ids renumbered), the task drawer (?task=: description, Definition of Done, capabilities, skills, context group, its own agent, outputs) and the planner chat — the editor assistant on the draft, whose suggested YAML replaces the draft; “Open blueprint editor” on the scenario’s Definition and the Cmd+K blueprint hits land here- Saves to the brain repo —
blueprint.write’s dry run first (each issue on its field and tab), then the commit against the plan file’s sha as the editor opened it; a file another writer moved since is a conflict that opens the merge view (theirs against yours as YAML: Take theirs, Keep mine, or merge by hand); a locked plan takes no structured save; Cmd/Ctrl+S saves; Plan preview (scenario.plandry run) loads the planner’s plan as the draft - Unsaved-work guard —
useBlockeron a dirty draft plusbeforeunload; git in the brain repo is the only history (no server-side drafts or revisions by design) - Raw YAML — on the scenario page’s Definition (
?view=blueprint, Edit YAML):blueprint.getreads the file and its sha,blueprint.writewithyamlsaves it (which locks the plan — the first edit asks) on that sha,blueprint.set_locklocks and unlocks
- Saves to the brain repo —
- One conversation: the chat dock (since 1.22.0) — every “Ask” in the dashboard opens the chat dock (the chief’s DM, the same thread as Telegram) with typed refs and a draft, never page content; a page offers the dock’s empty thread its own starter chips (
useDockStarters) — the doctor’s “What should I fix first?” with aquery:doctor.checkref, a chat’s “Who can talk here?” with itsbot+chatrefs. There is no separate dashboard helper agent - One editor popup for every brain file — role prompts, SKILL.md, scenario markdown, hook templates, eval methods, heartbeat prompts, blueprint YAML and webhook payloads all open the same editor popup: CodeMirror on the dashboard’s own colours left, a tabbed pane right (Preview / Assistant — the editor assistant below / Reference), collapsing to tabs below
md- Preview follows the content type, not the call site, so no markdown editor ships without a rendered view
- A dirty draft always asks — Escape, outside click and the close button funnel through one guard, and
beforeunloadcovers reload and tab close
- Editor assistants (since 1.22.0) — the editor popups with a chat tab (scenario prompt, blueprint YAML, input schema, SKILL.md, role instructions) carry an Assistant: a read-only helper for one draft with a stable
editor:<domain>:<id>agent name, running the system rolesystem:editor-assistant(role-editor-assistant.md; a brain overrides its prompt or its read-only grant by shipping its own copy);editor-chat.findAgentresumes the previous thread,sendMessagemints it lazily and puts an<editor-context>block (the editor’s title and the edited object) and the unsaved draft as a<draft>block in front of the message — the thread shows only the person’s words — and theeditor:name prefix is a hard contract preventing that block from reaching a non-editor agent. Insert into editor applies the code block of its latest reply to the draft (nothing is saved until you save); Continue in<persona>closes the editor (a dirty draft asks first) and opens the chat dock with the edited object’s ref - Master–detail catalogue pages —
/system/events/types,/permissions/tools,/connections/inbound,/permissions, Knowledge › Skills (/knowledge/skills) and Knowledge › Memory (/knowledge) share oneMasterDetailcomponent: a filterable list beside a pinned detail frame whose active tab owns its own scroll, selection in?selectedId=/?detailTab=, one switched pane belowmd(apps/dashboard/docs/pages/README.md§ The master–detail pane) - Tool catalog (
/permissions/tools) and capability policy (/permissions) — every registered engine/brain tool with risk, category,providesCapabilities, source, derived HITL requirement, input and output schema (field tables + raw JSON tabs) and inline usage stats; every capability with its gate (hitl), risk tier, description, providing tools, requiring scenarios, active grants and waiting cards — and the gate is editable in place: the row switch and the panel form writeconstitution/engine-capabilities.json/project-capabilities.json(CAS on a file hash, one confirmation before a gate is removed, an invalid file is never rewritten), the raw file opens in the shared editor popup, and the posture band reports each file’s sync state; both pages filterable and deep-linkable via?selectedId= - Tool Usage Stats (
/permissions/tools/stats) — range-windowed (1h/24h/7d) analytics: a KPI row (total calls, error rate, denied, global p95, active tools), a call-volume trend with error overlay, breakdowns by risk / source / error type, and a sortable per-tool table with share-of-total- Per-agent slice from Postgres — read from
conversation_events, the only agent-attributed view, since Prometheus tool metrics carry no agent label; Prometheus unreachable renders as a degraded notice, not a 500 (tool_summaryanswers the same: its Prometheus sectionsnull, the by-agent slice still there)
- Per-agent slice from Postgres — read from
- Audit (
/audit, opening on Cost) — accountability in one place: Cost, Actions (/audit/actions: the action log,audit.list, as a plain list newest first) and Budgets, with Keep records behind the item bar’s ⚙ — how long the action log and the spend ledger keep their rows (manifest.set_retention) and how long handled Telegram / GitHub payloads stay (manifest.update), each typed and previewed- Budgets (
/audit/budgets) — every money limit (budget.status: the brain’s, each scenario’s, each agent’s; per day, per month, a scenario’s per run) with what is spent, left and raised, the request that speaks for a stopped one (→ the Inbox); Change limit previewsbudget.updateand asks before a raise or removal (budget.widen), Raise for this window adds money to the current window only (budget.raise)
- Budgets (
- Audit › Cost (
/audit/cost) — token and spend analytics with deep URL state (windowincl. calendar-anchoredtoday/yesterday/mtdvs rolling24h,all, wall-clockfrom/to;metric,bucket,stack,compare,unit,by,limit,view,sort) and a scope —feature,entity,run,session— that Cost of this on a run, a session, an entity or a feature opens- Every number is an op — the page binds the
usage.*ops an agent calls asusage_*tools, so it reads the spend ledger (reaped agents and compacted turns included) and computes nothing of its own; a run’s Usage tab reads the same ops filtered to the run’s execution - Real vs phantom split — subscription runs write an equivalent-API-rate
costUsd; real (metered) spend and phantom (subscription) spend are reported apart everywhere - Headline cockpit — total burn, period sparkline with previous-window ghost and hover-scrub tooltip, forecast gauge, and a six-micro-stat band (real / phantom / tokens / turns·conversations / model calls / compute)
- Signal strip + insights — three dials (cache leverage, eval coverage, unit economics $/succeeded-run and $/passed-eval) beside threshold-gated editorial callouts with advice (spend trend, cache leverage, retry overhead, subscription leverage, top-model dominance, eval fail/blind), capped at 4 and covering disjoint metrics
- Unified trend — metric + granularity switch, stack by none / metered-phantom / provider, period-over-period compare overlay with spike markers, Total vs $-per-turn lens; the trend is gap-free and zero-filled so two windows align by index
- Money flow — a Sankey (context→provider→model) or provider › router › model rings, plus a day-per-cell calendar (a day with failed turns ringed) and the hour × weekday heatmap
- Pivot breakdown — 13 dimensions — feature (the default), scenario, pipeline, agent, role, model, provider, router, context, conversation, actor (“who triggered”), run status, trigger; Pareto top-N caption with per-row cumulative Σ%, CSV export, bars⇄table (the model table, sortable), and on a run dimension how many runs succeeded and what a success cost (
costPerSuccessUsd) - Billing & plans — metered / subscription / untagged cards, cache split with estimated $ saved, plan table
- Outcomes & quality — spend by run status and eval outcome, token distribution, server-tool usage, the top-N runs of every kind with who started them
- Every number is an op — the page binds the
- Self-Evolving — the Evolution Trace (
/evolution) — the loop as one route with its state in the URL (?selected=<kind>:<id>&view=policy|ideas|logs&cycle=<id>&episode=<runId>&metric=<id>&knob=<id>, defaults omitted), in its own top-level nav slot. Every number on it is derived server-side from the engine’s own seams (evaluateTriggerWindow,nextAskAt/restingUntil,primaryObjective,listCyclesAwaitingPerson, the entity’s owncompare), never re-computed in the browser- Fleet — every improvable entity enumerated from resolved config as a card: a five-arc loop glyph (the live phase lit; the “you” tone while a person holds a gate; a danger dot when stuck; a dashed ring when off; a notch when the breaker demoted its gates), the entity’s state word and one-line status sentence, its objective as a mini trace cut at every change it shipped, and
objective first → now · N validated · M cycles— counts it earned itself, never a cross-entity rate. Entities not in the loop fold to one line pointing at Start a loop — the page header’s way back to the chooser, which lists them — graded ones first, then the ones people have tuned by hand most in the last month (read from the brain’s git history; the loop’s own commits do not count), the first suggested with those edits as its argument — each with a one-click Turn on (loop.join: one write that turns the loop on, resumes a paused entity and starts grading with the defaults, asking for a daily grading limit for an entity that runs on its own); the footer carries the loop doctor checks and the standing autopilot grants - Fleet grouped by feature — a brain feature with two or more entities in the loop renders as one collapsible group (
layoutFleet) with its held lock groups and the feature’s entities that could still join; a lone entity stays a plain card - “N need you” — exact, one list — the page header’s pill opens the only list of what a person owes the loop (decide / retry cards, grant requests, human merge and revert gates, re-arms, unread failures, config faults), each with its one move; everywhere else “needs you” is a tone, never a second list
- Entity — one entity, one time axis — the objective’s graded runs on a hand-drawn SVG trace with its level line: the engine’s median of the metric between two change events, stepping at every merge (and back at a merged revert), coloured by the cycle’s verdict, and breaking — not stepping — at a judge change, because levels under two judges are incomparable. Each cycle is written where it moved the line (◆ shipped, ○ not shipped), the selected cycle’s observe and prove windows are washes labelled with their phase names, pending grades are ticks — each says when it is due (
evaluation.gradeAfterHours), and the legend counts them with when the next one lands. The one headline number is the objective’s first level → now - The cycle as a five-phase story — Observe → Hypothesize → Change → Prove → Learn, a pure fold of the engine’s stages (window · analyze/freeze/decide · apply/check/merge · watch/revert · lesson) computed server-side; a stage that does not apply is not drawn, future phases are ghosted, never struck through. Chapters carry each round with the person’s refine note and author, the pre-merge check and the post-merge watch as a forest plot in each metric’s own units (or run dots for a
repaircount), the revert, and the lesson with what it later preceded; the aside carries producer, run key, the proposer each round ran on, rounds, duration and spend against the current ceiling, and the cycle’s memory both ways — the lessons it was handed and the later rounds that recalled its own, each with its outcome and verdict strength (cycle 3 · r1 · kept · paired) and a person’s Skip quoted with its author - Decisions answered in place — the decide / retry gate is a HitlGateway
approvalcard (producerloop), so the story’s waiting chapter answers it throughhitl.respond— Accept, Refine… (the card’s free-text reply) or Skip — exactly as Telegram and the Inbox do; no second decision channel. Analyze now (disabled with the producer’s own reason whenever it would not open a cycle), the entity switch, the header’s Repair | Evolve switch (entity tier), Turn on, Re-arm and dismissing a failure are the page’s other writes - Entity tabs — Evolution · Policy · Ideas · Logs — Ideas is the entity’s ideas bucket on its own tab (the queue’s size beside the tab’s name); Logs answers “what is it doing right now”: the loop’s live agents first — each proposer or implementer a cycle opened, with its last steps tailed live and its whole conversation one press away — then the entity’s last seven days of runs (its own runs, the grades of them, every cycle step — analysis, apply, pre-merge check, watch, revert) merged with its finished loop agents, newest first, each linking to its cycle, run or agent. An agent reads the same runs with
run_list(entityfilter) and follows one withagent_tail - Hints before a press — an action whose word does not say what follows (Hold, Release, Re-arm, Analyze now, Dismiss, Retry · Refine… · Skip, Withdraw, Turn on) says on hover and focus what it does, what happens next and whether it can be undone; a button that cannot act says why
- Chat role — its episodes — on a
roleentity the rating card gives way to the role’s episodes, paged newest first (start → close and why, turns, a span across role versions, the judge’s grade and recorded numbers, people’s ratings, a Rate on each), and Read the dialog opens?episode=<runId>— a sheet with the agent’s own timeline cut to that episode’s turns (agent.getConversationMessagesbyepisodeId), the page the rating card and the home page’s eval ledger link to - Policy view — the resolved
loop:config as one sentence per concern, every editable value a picker token with a chevron whose underline shows who set it (engine default dashed, the brain solid, this entity solid brand; derived values dotted; read-only values without a chevron), opening a knob editor with the value’s chain, a live sentence, an Apply to choice (only this entity / all entities / reset to the default) and Save. Writes go through theloop.setop (loop.widenfor an autonomy, perimeter or switch knob — the person at the page approves it): entity-tier into the file’s own frontmatter block, brain-tier as a byte-preserving splice ofconstitution/MANIFEST.yaml, both resolved the way the loop will resolve them before anything is written - Every loop setting is a knob — the same knob write is an agent’s
loop_set(orloop_widenfor a widening), keyed by the idsloop_policylists.loop.*(incl.autonomy.requireCalibration,group,proposer.{pipeline,modelRef,instructions}, and MANIFEST’sgroups.<id>),evaluation.*(enabledkill switch / opt-in as two halves,method,modelRef,requirements,gradeAfterHours,metricTolerance,scoreWeights,sample,judge.maxUngradedShare,humanwithagreement,context, andmetrics.<id>.<field>per field) and a chat role’schat.{episode.idleHours, onRoleChange}. Each knob carries its section, its merge (wholesale— the block written whole with the visible siblings;per-key— the one key into the target tier’s own block;append— the target tier’s own lines, read as the brain’s then the entity’s:proposer.instructions) and a consequence class the editor warns with: judge change (method,modelRef), comparability (requirements, a metric’scriterion,scoreWeights,context,chat.episode), slower or costlier grading (sample,gradeAfterHours,human.ask). Refused only where the schema has no tier, nothing reads it (lessons.retentionDayson an entity) or the metric spec refuses it (a catalog metric’scriterion, acriterionthe manifest already answers,criticalon a people-rated metric — each shown read-only with its reason). A candidate is checked against the manifest’s own judge and every loop entity with the real resolvers — metric spec, score weights that weigh something, method file, the judge’s and the proposer’s model pinning, the proposer pipeline — and refused only for a fault it adds, naming the entity, in the open editor beside its Save; anevaluation:edit on an ungraded entity writesenabled: falsebeside it (the block is the opt-in). Writes change only what differs (editYamlPath,@omnislash-ai/config): a scalar spliced in place, a key or list item added or removed, the author’s comments kept - Brain policy — the
loop.policyop brain-wide: the same knob views over an entity that wrote nothing (winningengine or brain), each withoverrides(count,of— how many entities the brain tier could reach, so a value all of them override reads as used by none — and the entities whose own tier wins it, a chat role outside the loop named without a link; none for a knob with no entity tier), the brain-wide metrics per field, thechat:knobs, MANIFEST’s lock groups with their members, and the loop code (loopCode: the functionsomnislash.config.tsloop:defines, the entities with their own<id>.loop.ts); edited throughloop.set/loop.widenwith no entity - Settings page —
/evolution?view=settings, from the fleet header’s Settings: the loop and grading kill switches on top, above the tabs — they outrank every entity, so they are not defaults (off asks first; on removes MANIFEST’sfalse) — then two tabs of brain-wide defaults in the URL (&tab=policy): Agent defaults (one sub-tab per agent — Judge / Proposer / Implementer,&agent=proposer|implementer, absent ⇒ the Judge) and Policy defaults. Settings → Policy defaults is the brain policy as rows:loop:/evaluation:/chat:rows grouped by the cycle step they govern (so the three inconclusive/unmeasured outcomes and the three deadlines each sit under their step), each with its value, who set it (engine / brain) and overridden in N entities linking to each entity’s Policy view with that knob open; the proposer’s and the judge’s model rows carry the Agent defaults tab’s model ladder; lock groups editable (terms, delete, Add a lock group) with their members; the brain-wide metrics as per-field chips; loop code named read-only — the functionsomnislash.config.tsloop:defines and the entities overriding them with their own<id>.loop.ts. Every row opens the same editor as the entity page’s tokens (KnobEditorover a per-family face — policy, chat, metric field, lock group — scoped to the brain tier), which shows the consequence class inline before Save - Entity Policy view — grading and episodes — under the loop sentences, the entity’s
evaluation:terms (its own grading switch, judge, sampling, bands, people’s ratings, judge context, each metric’s fields as chips) and a chat role’schat:terms as the same rows, scoped to the entity (Only this entity / All entities / Reset); the scenario page’s rawevaluation:form is gone — it bypassed this validation - Settings → Agent defaults — the loop’s own three agents as the brain’s files configure them — what every entity’s rounds run with unless the entity names its own model — one sub-tab each: the Judge (
system:evaluator, run and chat grading), the Proposer (system:loop:proposer), the Implementer (system:loop:implementer). Each shows its model ladder (brain-wide → role file → shared role defaults, the entity overrides — each a link to that entity’s Policy view with the model editor open — the chain it resolves to now and the harness it implies; the Implementer’s model comes only from its role file); its capabilities as ONE list with where each comes from — the role file’s own entries (removable), then the shared role defaults’ (read-only); All capabilities is'*', and an empty grant is the engine’s always-on escape hatches alone (asking a question, requesting a grant or a connection), which the card says; what that grant compiles to — on a CLI model the harness tool groups on or off (read files and plan — on for every role — file writes, shell, web, then the engine tools and MCP servers; a Claude model’s built-ins follow the grant on every turn, while a Codex or OpenCode model keeps all of its own tools on the Judge’s turn, which is not confined — the card says so, and so does a warning when the Judge or an entity’s own judge model runs there), on an API model the engine tools alone; where it runs (live brain; for the Implementer a sandboxed clone under activeisolation, an unjailed clone without, the live brain where no brain baseline exists to clone); its prompt modules, each saying whether it is the default or set in the role file, with a Reset to default; skills, MCP servers (JSON checked when staged) and body; the Proposer and Implementer prompts underconstitution/Loop/, each with who reads it (the sameproposerBriefPaththe cycle calls —proposer/ideafor a cycle opened on an idea, else the preset’s, else the kind’s;implementerfor every implementer turn), and for the Proposer the entities whoseloop.proposer.instructionsadd to its brief; the doctor checks that concern it (Eval judges, Loop role memory, Loop confinement) and the consequences of how it is configured. Every one of those is editable: any capability in the registry (or'*') — the engine never refuses a grant, it says what it means. Role edits Stage on the card, and Review and save asks the server for a dry run that shows the change (before → after) above what the write will do (a Judge change →judge_changedfor cycles in flight and calibration restarting; the Proposer with a shell in the live brain; the Implementer with a shell and no isolation; ahitl: truecapability underdecide: autofailingmissing_grant) — and refuses a role that would not load before the confirm. The write goes to the brain’sconstitution/Agents/system/role-<id>.md; the first one materialises the whole engine template there (frontmatter withcapabilitiesandpromptFlags, and the body) and patches that — a fork: from then on engine updates to the template stop reaching this brain. Prompt files are separate documents and Save now — written at once, no review. Prompt saves go toconstitution/Loop/proposer/<brief>.md/implementer.mdonly, and refuse a placeholder the engine does not fill. The proposer pipelines are the last sub-tab, read-only (loop.proposer.pipeline→ the entities that name it, each a link);loop.list_agents, the prompts throughinstruction.list/instruction.getand their saves throughinstruction.write_file(loop/…); role saves are previewed with arole.widendry run (Review) and committed throughrole.update, or throughrole.widenwhen they add to the grant — the person at the page decides, so no approval card — over the one role writerwriteRoleFile(@omnislash-ai/config/role-write) - One owner, one way in — every other page reaches an entity’s loop by a link to
/evolution?selected=<kind>:<id>(until Evolution gets its own pages), mostly through one of two chips. The entity, agent and blueprint-editor headers and the entity rows carry the Evolving chip while the loop is on for that entity, the loop’s sentence about it as the tooltip; while it is off, those headers show a quiet Loop off → link instead, so the pageloop_statuslinks always leads to the switch. The/system/schedulertables and the run list’s scenario groups carry the loop chip: the entity’s fleet row as a still loop glyph plus the fleet’s own state word (needs you,stuck, the live phase’s word while a cycle moves, such asproving;waitingwhen quiet,offwith the dashed ring), its one-line status as the tooltip, no counts, and aReview →cue on the larger chip (the run list’s scenario groups) when the entity needs you. An entity page shows the loop in its header’s chip and, on a scenario or heartbeat page, in its Overview’s Evolution band while the loop is on — the loop’s sentence about it, the live cycle’s step and the gates it owes you, linking to the loop view — and never writes to the loop: no Analyze now, no loop switch, no rawloop:/evaluation:form or blueprint-editor loop tab; the loop view’s entity header holds the switch, and the Policy view is the one policy editor.RunCycleChip(this run’s cycle status) rides run rows and headers, and a run’s Evaluation tab renders its one cycle with the same phase track and verdict record the story uses (loop_get_cycle)
- Fleet — every improvable entity enumerated from resolved config as a card: a five-arc loop glyph (the live phase lit; the “you” tone while a person holds a gate; a danger dot when stuck; a dashed ring when off; a notch when the breaker demoted its gates), the entity’s state word and one-line status sentence, its objective as a mini trace cut at every change it shipped, and
- Published Artifacts Management (Knowledge › Artifacts,
/knowledge/artifacts) — one page over every publication ever minted (live, expired, revoked): status/visibility/publisher filters, search, infinite scroll, audit trail (published/replaced/revoked by whom), inline content preview, title/name/expiry/visibility editing (un-expire included; making a publication private isartifact.update, making it public or renaming it isartifact.widen/artifact:widen— from here, or by an agent with a person’s approval), one-click revoke and delete - System Pages (
/system) — the System item’s tabs over infrastructure state (Overview · Doctor · Credentials · Context · Logs · Event stream · Event types · Terminal · Config · Schedule)- Overview — project info,
HealthCheckerresults (DB, Redis, queue), the Redis service registry with heartbeats, and a Prometheus metrics summary; Sync now runsbrain.sync(an immediate brain-sync tick) - Doctor — the runtime doctor report (configuration, infrastructure, connectivity — what the deployment can see from inside; Docker, SSH and the dev machine stay
omnislash doctor’s), failures and warnings first, each with its remedy readable at every width and/system/...pages linked. Fix with agent on every failing or warning row hands the finding — verdict, hint, suggested command — to an agent that acts on it (edits brain files, runs what its tools allow, asks you only for a decision or a secret, then re-runs the check) and opens its chat; one agent per check, so a second click resumes the thread. The role isdashboard.doctor.fixRoleinconstitution/MANIFEST.yaml, default the chief’s. Re-check re-runs the report; Ask<persona>about this check, beside Fix with agent, opens the chat dock with the check’s ref - Context — per-role context-budget report with caveats first, KPI row (prefix, window share, delivery, runs and spend in the window, “N tools / M used”), a Trim to used preview that applies through
role.update, severity-ordered findings naming the concrete lever, and a three-group breakdown - Credentials — the whole credential inventory, one section per lane (GitHub, CLI agents, connections, LLM keys, integrations, infrastructure, deploy/CI), worst first: status, expiry date with a countdown, where the value lives, what breaks while it is down, and the remedy with its command; flagged rows get Fix with agent, exactly the rows the
Credentialsdoctor check counts; agents read the same inventory throughcredential_list(runtime:read), the op the page calls —group(since 1.24.0) reads only some lanes and walks only theirs. Never a secret value
- Overview — project info,
- Web Terminal (System › Terminal,
/system/terminal) — real PTY shell sessions from the browser (xterm.js over SSE,terminal.write/resizemutations, no WebSocket);?service=<compose service>(localfor the dashboard’s own shell) opens one into that service or selects the one already open, which a session’s ⋯ Open terminal (the agents’ service,pipelines) and Cmd+K’s Open the terminal use- Two target kinds — the dashboard’s own shell opened in the brain root, and
docker exec -itinto any sibling Compose service discovered through the bind-mounted Docker socket (com.docker.compose.{project,service}labels, scoped to the dashboard’s own project); in dev and inomnislash start, discovery fails soft and only the local shell is offered - Production only, and it is root on the host — the socket mount is the full Docker Engine API, so this page is a root shell by construction and its Caddy
basic_authcredentials are root credentials.omnislash compose --prodtherefore refuses to build a domain-mode deployment (PUBLIC_DOMAINset, dashboard enabled) unlessADMIN_USERandADMIN_PASS_HASHare set.omnislash startpublishes the same page with no auth in front of it at all, which is why the socket is not mounted there - Reconnect-safe — per-session scrollback ring buffer replays on reconnect (
subscribe(afterSeq)); tabs survive reloads and SSE drops but not a server restart; hard cap of 8 live sessions - Operable from a phone — a coarse-pointer key row under the session (Esc · Tab · sticky Ctrl · Ctrl+C · arrows · Enter) writes the byte sequences a soft keyboard has no keys for, with the cursor keys following the session’s live DECCKM mode (and the CSI modifier form under a held Ctrl, so Ctrl+←/→ word-jump works); the arrow-driven
claude/codexlogin menus are the whole point of the page and were unanswerable from a touch device without it. Sticky Ctrl is spent by the next keystroke and by nothing else — xterm routes its own replies to a TUI’s startup probes (cursor position, device attributes, colour, focus, mouse) through the same data event, and those never consume it. The row’s taps ride the same ordered write queue as typed input and snap a scrolled-up viewport back to the prompt, as a real keystroke does, and the page pads itself out from under the on-screen keyboard (measured againstvisualViewport, since no browser shrinks the layout viewport for it) so the row stays reachable while typing
- Two target kinds — the dashboard’s own shell opened in the brain root, and
- Env Var Catalog (System › Config,
/system/config) — every environment variable the engine and brain recognise (merged viadefineProject({ env })), grouped by category with description, default and present/missing state; values are never sent to the browser; below it every MANIFEST.yaml key as the engine uses it, with whether the file sets it, each changed in place (typed by its value,manifest_update), reset to the engine default (manifest_update’sunset) or asked about - Event Schema Registry & Ingest Log (System › Event types,
/system/events/types) — the external-event ingestion log (source, type, status, idempotency key, expandable JSONB payload, processed-at, error) beside a generated catalogue of every engine event schema with domain, description, field list,system/usercategory and an auto-generated example payload derived from the Zod schema - Webhook Endpoint Catalog (Connections › Inbound,
/connections/inbound) — everyPOST /tool/:nameandPOST /event/:sourceendpoint with a KPI summary (totals, tool/event split, events today, error rate, risk distribution), per-source volume, copyable URL, a generated curl and the tool’s JSON Schema- Auth door per endpoint —
endpoint_listanswers each row’sauth(shared-secret, orsource-definedwhere the source’s declaredverify()REPLACES theWEBHOOK_SECRETcheck) and the note, Auth field, legend and curl all branch on it; the curl’s origin isendpoint_list’shttp.baseUrl—WEBHOOKS_PUBLIC_URL, else the webhooks app’s loopback port, reachable on its own host only (since 1.25.0); the open tool’s schema isendpoint_get’s
- Auth door per endpoint —
- Integrations & Connections (Connections › Services,
/connections) — read-only inventory of third-party providers (name, type, status, env keys, configured models/endpoints) plus a connections tab: OAuth connection list with reconnect, force-refresh and revoke, and an MCP server catalog with cursor-paginated browse and one-click install (OAuth redirect or BYOT token) - Repos (Connections › Repos,
/connections/repos) — the whole connect → install → attach path without a CLI, on one screen: a header line with the App (Connect navigates to the webhooks app’s registration page — the same link an agent’sgit_request_appcard carries — which form-POSTs the manifest to GitHub’s registration screen, and registration continues straight into GitHub’s install screen; once registered, “Signs as” the App’s slug linked to its page plus the numeric id; secrets are never part of the tRPC payload) and one section per account it is installed on (org/user,repository_selection, whether the last listing reached it, “Add account” → GitHub’s install screen, “Change selection” → the installation’s own settings page). Each section holds its attached checkouts as tiles — slug, branch, sync state in words withlastSyncAt/lastError, visibility, Open on GitHub and Detach — filed under the account their remote’s owner names, and beside them what the installation actually reaches (one liveGET /installation/repositoriesper installation) as dashed Attach tiles, each opening an Attach dialog for slug and optional branch (both fields empty by default, so each takes the engine’s own default — a slug normalized to the slug charset, a branch of “ask the remote” — and a slug another repository already holds blocks Attach instead of silently re-pointing that row). An installation that cannot be listed gets an inline error line with Reconnect instead of hiding the ones that can; checkouts no installation serves sit in their own trailing section - Global Search Palette (Cmd/Ctrl+K) — one palette on every dashboard page (full screen on a phone): a pasted ref, prefixed id or atlas id offers “Go to”; the pages of the sidebar’s navigation match client-side with no server call; one
brain.searchop (the same answer an agent gets frombrain_search) fans outILIKEand in-memory filters (no index, no FTS) over runs, agents, features, scenarios, heartbeats, hooks, roles, blueprints, skills, pipeline runs, cycles, artifacts, requests and (since 1.24.0) capabilities, tools, bots, chats and people — each hit carrying the ref it opens (run:…,session:…,feature:…,request:…,tool:…) —, over-fetched by recency and ranked in Node into fixed-priority groups that flag truncation and name kinds it could not search; “Search in …” hands the text to memory, logs or events; a leading>lists the open object’s actions and the global commands (since 1.22.0) - Natural-Language Input Translation —
input.translateturns a free-form description into a structured object satisfying a blueprint’s or pipeline handler’s JSON Schema — “Describe in words” on every typed input form (a scenario’s Run with input, an input request’s answer) —, returning a discriminated result so parse errors render inline - Unified File Preview — one payload (
buildFilePreview) and one component render markdown, HTML (sandboxed iframe), JSON, CSV tables, syntax-highlighted code, images, PDF, audio and video, with Download as the fallback; a single extension→language table (shikiLangForExtension) is shared by work-log, tool output and file preview - Design System — a leaf zone of shadcn-derived (on Base UI) and tone-aware primitives with a three-tier token model (primitive OKLCH → semantic
@theme→ component), themed only at tier 2- Three painted themes —
light/mid/darkapplied as adata-themeattribute written before first paint to avoid FOUC (a person may also picksystem, which paints the device’s light or dark); components never know the theme - Multi-tenant branding —
--color-brand/--color-brand-mutedoverridden via[data-brand], with a documented hue-collision constraint against semantic and accent slots - Semantic scales — typography (
text-display-xl…caption), letter-spacing, motion durations, page-width containers, radii and spacing, each with a documented promotion threshold - Resizable modals — every
Dialogand sideSheetdrags wider or narrower from its free edge (ModalResizeHandle, min 320 px, viewport-clamped), the width kept perresizeKeyor size class inlocalStorage - Enforced by lint — repo-local GritQL checks and zone import bans (no colour literals, no raw text sizes — Tailwind’s own scale is reset too —, no
confirm(), nodocument.bodyportals, no ad-hoc placeholders, no query without its liveness, no serverconsole, no import across a zone’s public surface)
- Three painted themes —
- Phone-First Shell — one chrome around every page, its navigation read off the routes’ typed
staticDataand one level deep (12 items in three unlabelled blocks divided by a rule — what you act on, the operation, and its settings (Bots · Connections · Permissions · System); an item’s other pages are the item bar’s tabs above the page, its brain-wide values a ⚙ side sheet): a desktop sidebar (Expand · Rail · Hidden with an edge peek; a full-width workspace page forces Hidden) with live counts (requests waiting on you, runs and sessions running, evolution gates, flagged checks) and status dots said in words (a feature failing, model or deployment credentials, bot setup, a budget past 80 %); on a phone a bottom bar (Home · Inbox · Features · Evolution · the chief by persona, opening the chat — a dot on it says working · needs you · new) and a More drawer (the other items in the same blocks, Runs first, and preferences); a notice row for persistent system states (offline first); crumbs and a(N) <page> · <brain>tab title; preferences in one menu (theme, sidebar mode, desktop notifications for a request that starts waiting on you) — and Stop everything…, the emergency stop (system_stop: the scheduler braked, every live run paused, every agent in a turn stopped), whose notice “Everything is stopped” offers only Resume (system_resume) while it stands; installable to a phone’s home screen (web manifest, standalone; no service worker — Telegram stays the push channel) - Mobile-Ready Control Plane — the whole dashboard works from a phone: overlay primitives clamped to the viewport,
<DesktopLabel>collapsing icon+label chips to icon-only without losing the accessible name, the shell top bar’s slot for a page’s phone actions, phone-specific layouts on logs/agents/evolution/events, bottom-sheet filter shells; the/atlasstage stays usable on a phone (a one-line status chip, the entity card as a bottom sheet) - Dashboard Smoke — every route the router knows (the route tree, not a hand-kept list), param routes filled from typed seed anchors and search-param variants, shot on desktop and on a 390×844 touch phone loaded at that width: fails on an uncaught or console error, an error boundary, a failed tRPC call, sideways scroll, or the page’s last control hidden under fixed chrome. Backed by one committed seed world (
e2e/world/): one brain with a stress feature (a broken file, a Late and a Down clock, a gated and an inline hook) and two layers of rows on it — a presentable one and one that deliberately breaks layouts (unholdable names, already-expired grants, every run and request state) — on the engine’s current data model: prefixed ids and anexecutionsrow for every run of every kind, written through the engine’s own writers, and checked without a database against its brain, its anchors and a coverage list, because an empty database hides exactly the bugs smoke exists to catch - Dashboard verify loop —
stack(Docker Postgres + Redis, migrations, seed, dashboard; idempotent, its own containers and ports per checkout),shoot <route…> | --changed | --all(PNG per viewport + accessibility snapshot,--assertfor the smoke failures) andsmoke, so an agent sees its change on desktop and phone before a person looks - Marketing Capture Harness — the dashboard world’s studio layer (
e2e/world/studio/), a deliberately realistic month of one fictional studio, Sixty Seconds Studio, on the world brain (e2e/world/brain, a workspace member, whoseREADME.mdis the world bible), written through the engine’s own writers: deterministic prefixed ids from one shared contract (STUDIOine2e/world/anchors.ts) and one world clock (WORLD_NOW), loop stories whose every verdict the engine’s own comparator computes and the layer asserts (world.test.ts); brought up bystack --world studio(a copy of the brain without the edge feature, presentable env, the seed’s clock printed asSHOT_NOW) and shot by a declarative Playwright manifest whose every slot carries arequiresgate, shot in three phases inlight/mid× desktop/mobile;SHOT_NOWpins the page clock so re-runs are byte-stable; the manifest is validated in the dashboard lint (every live slot’s route exists, a deleted page’s slots are markedretired)
Events System
- Typed Event Infrastructure on Redis — two complementary abstractions in
@omnislash-ai/events:EventBus(pub/sub, real-time, fire-and-forget, multi-subscriber) andStreamBus(Redis Streams, persistent, at-least-once, consumer groups) - Event Registry — 126 typed events across 20 domains — every event is a Zod schema in
packages/events/src/schemas/<domain>.ts, merged intoallEventSchemas; payload types auto-derive asEventPayload<'domain:event'>workflow:(24) — run started/completed/failed/cancelled/paused/resumed, phase and task lifecycle, task progress and retry, the fourrecovery.*events, and seveneval.*eventspipeline:(18) — run started/completed/failed/cancelled/paused/resumed/killed/progress, step started/completed/failed/retried,step.agent_event(live nested agent progress), and fiveeval.*eventsagent:(12) — created, started, completed, input_request, cleared, context_threshold, fallback, circuit_breaker, auth_issue, tool.executed, resumed, memory_flushedplanner:(8) +planning:(1) — session/stage lifecycle, clarification, cross-process cancel request, blueprint ready, and the materializedplanning:session.snapshotheartbeat:(6) — completed plus fiveheartbeat:eval.*eventsscheduler:(6) — scenario skipped, remote command, reconcile completed/failed, pipeline and heartbeat triggersmemory:(7) — written, edited, deleted, compacted, consolidated, synced, budget-refusedloop:(19) — the fouranalysis.*events plus the fifteen transitions past them (accepted, skipped, failed, applied, validating, validated, regressed, unvalidated, reverted, checked, watched, revert.settled, merge.gated, merge.demoted, paused), each namingtargetKind+targetIdqueue:(3) — job started/completed/failed for every BullMQ queuemtproto:(8) — account authorized/removed/default-changed, used for cross-process pool cache invalidation; the inboundmessage.received/message.edited/message.deleted/message.readandgap, published by the worker’s listener for accounts some hook subscribes to (telegram-account.md)hook:(3) — executed, failed, and skipped (a gate said no)hitl:(2) ·grant:(2) ·brain-sync:(2) — request/response pairs and lifecycleboss:·chat:·connection:·evaluation:·ingest:(1 each) — boss-run expiry, chat turn completion, OAuth refresh failure, the materializedevaluation:session.snapshot, and external event ingest
- Subscription Surface —
emit(non-throwing; failures go toonError) appends to the event log and publishes; read live withobserve(awaits the Redis SUBSCRIBE confirmation),observeOnce(auto-unsubscribing),observePattern('domain:*')via PSUBSCRIBE with discriminated narrowing andobserveAll— a single PSUBSCRIBE over every known event, which is what the dashboard activity collector uses; read durably withconsume(group, handler)— a consumer group on the event log: once per group across replicas, caught up after a restart, at least once, handed each event’s log entry id- Connection economy — one shared subscriber connection per instance, multiple handlers per event share one Redis subscription, and the channel unsubscribes when the last handler is removed
- Agent Output Stream — the only registered Redis Stream, keyed dynamically per agent (
agent:{agentId}:output), carrying a 22-variant discriminated union (content.delta,item.*,turn.*,turn.plan.updated,tool.progress,task.*,hook.*,hitl_request/hitl_response,session.cleared,model.rerouted,runtime.warning/error/retry) over a common{ eventId, provider, threadId, createdAt, turnId?, itemId?, inputIds? }envelope- Read surface —
appendAs,readLastAs,readFilteredAs(forward scan),readLastFilteredAs(backward XREVRANGE),consume/consumeAs/consumeBatchwith consumer groups,claim,pending,moveToDlq,forceRemoveConsumer
- Read surface —
- Stream Reliability — consumer-group recovery (idle messages auto-claimed via XAUTOCLAIM after
claimStaleMs), a dead-letter queue at{stream}:dlqpastmaxDeliverieswith move metadata, poison-pill protection (messages failing Zod deserialization go straight to the DLQ and are acked so they cannot redeliver forever), exponential consumer-loop backoff to 30s, and a graceful shutdown that caps the block timeout at 1s and waits out the current batch - Validation at Both Ends — every payload is Zod-validated on emit and on receive; an invalid emit surfaces as
EventError(VALIDATION_FAILED)throughonEventErrorinstead of throwing, an invalid receive skips the handler, and a closed set of error codes (NOT_INITIALIZED,PUBLISH_FAILED,SUBSCRIBE_FAILED,STREAM_*,CONSUMER_ALREADY_EXISTS,CLAIM_FAILED,DLQ_MOVE_FAILED,EVENTS_TRIMMED,EVENT_DROPPED) names every failure mode - Wildcard Type Utilities —
WildcardPattern,WildcardMatchandWildcardPayloadderive the legal patterns and their payload unions from the schema map, soobservePattern('workflow:*')narrows correctly at the type level - Request-Response over Pub/Sub —
emitAndAwaitReply({ request, payload, response, match, timeoutMs })subscribes before emitting (so a fast responder cannot win the race), resolves with the first correlated reply ornullon timeout, and always releases the subscription; used by thebrain-sync:command→brain-sync:completedpair - Event Matching Utilities —
isKnownEvent,matchesEvent(exact orprefix:*) andmatchesFilter(exact payload-field match), exported for hooks, outbound dispatch and filtering - Singleton Wiring —
initEvents(redis, options)builds theEventBus+StreamBuspair with every schema registered; mutex-serialized, idempotent for the same Redis, auto-closing on re-init with a different one.events()throwsNOT_INITIALIZEDif called first, andresetEvents()exists for tests - Exactly-Once Discipline for Consumers — a documented classification (observational / work-triggering / delivery) plus three sanctioned patterns for work-triggering consumers, because observing over pub/sub fans out ×N on multi-replica workers and drops anything landing in a restart window
- P1 — durable owner inline — a journaled
ctx.runstep in the Restate handler that already owns the entity - P2 — consume + keyed job —
consume(group)on the event log, then a BullMQjobIdkeyed by the log entry id (one per emission), so a redelivery collapses onto one job with free retry and DLQ — global hooks, outbound dispatch, the boss wake, the recovery spawn - P3 — CAS / idempotent effect — a guard at the effect itself (unique index, DB CAS, awakeable resolve-once, virtual-object idempotency key)
- Delivery restart-loss → boot scan — a card dropped in the emit→send window is recovered by scanning the owning table on boot and re-driving un-delivered rows through the same render path, never by re-emitting
- P1 — durable owner inline — a journaled
- Resource Watches — wake the agent (or the pipeline) that is waiting on a resource — an inbound event carries correlation keys (
external_events.resources:github:pr:<owner>/<repo>#<n>,github:issue:…,github:commit:…@<sha>,github:ref:…, or your own source’s keys viadefineEventSource.extract → resources), and a watch subscribes ONE target to one keywatch_create/watch_list, andwatch_delete(thewatch.*ops) — an agent subscribes itself (capabilityevent:watch, moderate; a PR URL or a key, normalized; a person names the agent withagentId):note(quoted back in every batch),ttl_hours,end_on(default by key: a PR →github:pull_request.closed, an issue →github:issues.closed),since(also deliver what was recorded since then),debounce_seconds,max_deliveries,authors,include_own,keep_stale. Watching the same resource again renews it (new expiry, note and settings; budget restarted) — one watch per agent and resource.watch_list(runtime:read) answers “what are you waiting for” — yours, an agent’s (agentId), a resource’s, or a run’s (runId: its agents’ watches and the pipeline’s own waits): every watch with what is pending, delivered, and filtered and why;agent_getlists the agent’s watches too.watch_delete({ id })(event:watch, recorded in the action log) stops one agent watch by itswatch_…id (every batch names it) — an agent only its own, a person, the dashboard or an MCP session any; a waiting pipeline’s or a cycle’s watch is refused — its owner ends it- One batch per burst — a debounced delivery (
debounceMsafter the newest event, never longer thanmaxWaitMsafter the oldest) hands the agent ONE inbox entry per batch — a review with its six inline comments is one turn, served on its own (never merged with chat messages), under the auth of the turn that created the watch. The batch is an engine header (resource, counts, what was filtered, your note) plus every event inside one untrusted-content fence, and a footer framing it as a signal to re-read the resource - Who gets through —
authors: trusted(default) admits repository owners, members and collaborators, and bots by account type (a review bot’s association isNONE);alladmits everyone, fenced as always. Your own App’s acts are echoes, dropped (dropEcho); a check result about a commit a push has replaced is stale, dropped at delivery (dropStaleRevision; the push counts even when it was your own, filtered as an echo, so CI on your new head is never mistaken for stale) — a review on an earlier commit is still feedback and is kept. A terminal event bypasses every filter (your own auto-merge closing the PR still ends the watch). Every dropped event is ledgered with its reason, never silently lost - How a watch ends — ONLY in a delivery: its terminal event (a final batch says so), its expiry (
ttlHours, capped bymaxTtlHours; one final message tells the agent the silence is its to interpret — extend, remind a person, or close), or its delivery budget (maxDeliveries).watch_deletestops it at once; deleting the agent removes its watches - Durable, not best-effort — a per-watch ledger (not a cursor) records every admitted event once, the claim of a batch and its inbox entry commit in ONE transaction, and a sweep re-drives an expired or stuck watch; a retried route or delivery never delivers twice. A workflow task’s or pipeline step’s agent cannot hold a watch — the pipeline waits itself (
subscribeResource, Durable Pipelines) - Brain-tunable —
events.watchinconstitution/MANIFEST.yaml(read at every use, so it reaches watches already running), per watch through the tool’s arguments - The GitHub PR feedback loop — a coding agent watches the PR it opened and gets reviews, comments, settled CI (
github:checks.settled), a new conflict (github:pull_request.mergeability) and the merge as batched turns; a watched PR is also probed every 5 minutes, so a CI result or a conflict reaches it even when GitHub sent nothing.brain-example/skills/github(references/pr-feedback.md) is the ready skill — integrations.md § GitHub PR feedback loop
- Snapshot Channels —
PlanningChannelandEvalChannelreduce a domain’s raw events into one materialized Redis snapshot (planning:session.snapshot,evaluation:session.snapshot) that consumers subscribe to instead of fanning out across primitives- Single reducer owner — a
RedisLeaderLeaseonreducer:leaderelects one worker replica to run the reducers, because read-modify-write snapshot reduction is not idempotent; the lease releases by CAS on clean shutdown so a survivor takes over fast - Heal-on-acquire — the new
EvalChannelowner reconciles every still-runningsnapshot against the durableevaluationsrow, healing terminals that landed in the failover gap - Atomic start — the shared
SnapshotChannelbase attaches reducers through a collector, so a partial-subscribe failure detaches everything that did attach and leaves zero live reducers
- Single reducer owner — a
- Event Category Metadata — a schema may carry
.meta({ category: 'system' | 'user' }); the dashboard’s event stream (/system/events) hidessystemevents by default and the event catalogue reads the flag back throughz.toJSONSchema() - Event catalogue —
event_list(event:read, safe) — every event the engine emits: its domain, description, category, payload fields (path,type,optional,description, one level deep) and a sample payload (the schema’s ownexamples, else made up per field name), with the hooks declared on it (scenario-inline ones included). Built from the Zod schemas (@omnislash-ai/events/catalog); what a hook’sevent:names and what a Test-fire runs on - Event Log —
EventBus.emitpipelines anXADD … MAXLEN ~intoomnislash:events:tailwith its pub/sub publish: the capped log theconsumegroups read (~50 h of a busy brain’s events) and the debug views page (readEventTailnewest first,followEventTaillive); never a system of record - Invalidation Topics —
@omnislash-ai/events/topicsmaps every bus event (exhaustively, by type) to what it touches:run:<id>,entity:<kind>:<id>,inbox,session:<agentId>(an agent row’s status and turn start/end) and the kind-wideruns(what a run’s row shows changed: an execution started or changed status, or a task or step of it changed state or a phase finished — what a run list refreshes on; a heartbeat tick that ends before dispatch — skipped, definition missing or unparsable, failed pre-dispatch — emits no event, sorunsdoes not announce it); the dashboard’schanges.onTopics({ topics })SSE adds thefeature:<id>of each entity server-side and streams only the topics a client asked for — live only, the client refetches on reconnect- Topic-driven refetch (
app/live) — onechanges.onTopicsstream per tab over themeta.topicsof on-screen queries refetches what a fired topic names (kind-wide at most every 2 s, per-object every 250 ms), every active query after a drop;useCommandstays pending untilmeta.invalidateshas refetched
- Topic-driven refetch (
- Dashboard Event Stream — one SSE subscription for the whole SPA (
activity.onEvent, a live follow of the tail) and an in-tab bus fanning the same entry to the activity feed and the live mark - Extension Contract — adding an event is five mechanical steps (Zod schema in the domain file with
.describe()and optional category meta → domain map →allEventSchemas→ package re-export → emit/subscribe); types derive automatically and the dashboard catalogue picks it up with no further wiring
Media Processing
- MIME & Media Classification (
@omnislash-ai/media) — the leaf package every other layer depends on:MediaKind/MediaAttachment/MediaUnderstandingKind/MediaUnderstandingOutputtypes, magic-bytedetectMime({ buffer, headerMime, filePath })with sniffed > extension > header precedence,mediaKindFromMime,normalizeMimeType,extensionForMime,isGifMedia, GIF/audio/image/video predicates, and 30+-entryEXT_BY_MIME/MIME_BY_EXTlookup tables- Size limits —
maxBytesForKind(image 6 MB, audio 16 MB, video 16 MB, document 100 MB) plus the transcription-specificMIN_AUDIO_FILE_BYTES,DEFAULT_AUDIO_TIMEOUT_MS,DEFAULT_AUDIO_MAX_BYTES(20 MB network) andMAX_LOCAL_AUDIO_BYTES(100 MB local, deliberately far above the network cap because MTProto-sourced files exist precisely because they exceed the Bot API 20 MB limit)
- Size limits —
- Media-Understanding Provider Contract — an OpenClaw-shaped
MediaUnderstandingProvider { id, capabilities?, transcribeAudio?, describeImage?, describeVideo? }with credential-free request types (apiKey/baseUrl/headerscome from DI at construction, never per call) and result types kept 1:1 with upstream, so a downstream consumer can implement it without re-deriving the shape - SSRF-Guarded Media Download —
fetchRemoteMedia({ url, maxBytes, signal?, timeoutMs?, skipSsrfCheck? })is the one way media enters the engine- Rebind-safe — an
assertSafeUrlpre-gate plus a connect-time DNS pin that re-validates every redirect hop’s resolved IP, so the socket only ever connects to a validated address - Bounded — Content-Length pre-check plus a streaming
TransformStreambyte cap that errors before the body fully buffers, per-phase undici timeouts (headersTimeout/bodyTimeout/connect.timeout) and callerAbortSignalcancellation - Filename & type recovery — RFC 6266 / 5987
filename*=-aware Content-Disposition parsing, MIME detection viadetectMime, automatic extension append - Typed failures —
MediaFetchErrorwithcode: 'max_bytes' | 'http_error' | 'fetch_failed'so tool wrappers can branch into a structuredfailure(...)result
- Rebind-safe — an
- Image Operations (
@omnislash-ai/media-processing) — sharp-only and lazily loaded so the dependency stays optional:getImageMetadata,normalizeExifOrientation,resizeToJpeg(mozjpeg),resizeToPng,convertHeicToJpeg,hasAlphaChannel, andoptimizeImageToPng— a grid search over side lengths and compression levels to hit a byte budget - PDF Extraction (
@omnislash-ai/media-processing) —extractPdfContent({ buffer, maxPages, maxPixels, minTextChars, pageNumbers? })extracts text viapdfjs-diston a worker thread and falls back to rendering pages as pixel-bounded base64 PNGs via@napi-rs/canvaswhen the extracted text is belowminTextChars; if canvas is unavailable the result is text-only. It takes ownership ofbuffer: the bytes move to the thread and the caller’s view is left empty - Document Extraction (
@omnislash-ai/media-processing) —classifyDocument(mime, bytes)sorts a file into PDF, an office format, text, or unsupported (image / audio / video / legacy.doc/.xls/.ppt/ other binary), reading extension-less bytes as text when they are valid UTF-8;extractOfficeText({ buffer, format })turns docx, pptx, xlsx, odt, odp, ods, rtf, epub and HTML into markdown viaofficeparser, tables kept as markdown tables and each spreadsheet tab under its ownSheet: <name>heading, on a worker thread like the PDF path, taking ownership ofbufferthe same way - Multi-provider audio transcription — a brain-declared chain —
transcription.chain(the STT mirror ofspeech.chain) declares the order;MediaUnderstandingServicewalks it with automatic failover, defaulting to Groqwhisper-large-v3-turbo→ OpenAIgpt-4o-mini-transcribe→ Deepgramnova-3→ local whisper. Since 1.24.0 a role’stranscription:block (hot, written byrole_update) replaces the brain’s block whole for voice messages in the chats that role serves and itsaudio_transcribe, and a whisper step (groq,openai,local) takes aprompt— vocabulary or a mixed-language sample, the way to hear a speaker of two languages;transcription.languageis accepted by the brain’s schema too.- Terminal step needs neither network nor key — the chain ends in a
whisper-ctranslate2subprocess, so a brain that enabled that skill keeps transcribing while every provider is down, rate-limited or unpaid - A keyless step is skipped, not fatal — a step whose API key is absent is dropped at boot with a warning, so a chain naming a provider you don’t pay for still boots
- One door for everyone — chat ingest, the ambient auto-post, thread-root backfill and the
audio_transcribetool all go throughMediaUnderstandingService; each hears with the chain of the role that answers (the chat’s role, the calling role — since 1.24.0), and the hearings share themedia_transcriptscache per file and chain; brain-facing detail indocs/brain-dev/voice.md - Two input forms, both a file on disk —
TranscribeInputtakespath(fenced and capped byMAX_LOCAL_AUDIO_BYTES) orurl(SSRF-guarded fetch into a scratch dir, capped byDEFAULT_AUDIO_MAX_BYTES), with aMIN_AUDIO_FILE_BYTESfloor that skips empty or corrupt audio - Declared per-step limits —
TranscriptionEntry.limits(maxBytes/maxDurationSec) marks a stepskippedwith both numbers rather than running it, carrying each model’s own caps: 25 MB for groq and openai, plus the 1500 s ceilinggpt-4o-*-transcribeenforces at any bitrate - Speech track before upload — a file over the chain’s smallest declared
maxBytesis transcoded once to 16 kHz mono opus (extractSpeechTrack) and every step runs on that; a larger re-encode or a missing ffmpeg falls back to the original file - Decision record — every attempt is recorded in a
MediaUnderstandingDecision(attempts[].outcome: success | skipped | failed, pluschosen) for observability of what the fallback chain actually did, and a failure carries which provider failed and why instead of a bare null - Retries owned by the chain — each provider call runs with
maxRetries: 0; failover, not per-call retry, is the recovery mechanism
- Terminal step needs neither network nor key — the chain ends in a
- Persistent Transcript Cache (
media_transcripts) — transcription is idempotent and costly, so every engine consumer reads through one primitive (transcribeThroughCache) keyed by the file’s platform-stable identity (MediaAttachment.fileUniqueId) and, since 1.24.0, the chain that heard it (a role’s own transcription chain keeps its own transcripts), storing{ text, provider, model, durationSec, createdAt }- Immutable rows — first write wins (
onConflictDoNothing), so racing turns converge on one transcript - Provider-free hits — a cache hit needs no transcription provider at all and never touches the media source, so lazy source thunks stay uncalled
- Single-flight on the key — a
KeyedSingleFlightbeside the read-through cache collapses readers that arrive together, so concurrent consumers of onefileUniqueIdheard by one chain share a single download and a single chain run
- Immutable rows — first write wins (
- LLM-Facing Media Tools — four tools registered on the integration tool registry, all under the
media:processcapability; the three that read media download it throughfetchRemoteMediaand translateMediaFetchErrorinto a toolfailure(...)image_generate— runs theimagerole ofconstitution/models.json(apiChains.image) through the AI SDK, falling over step by step like any role chain — a retired model at the head costs one failed request, not the tool; a brain that declares noimagerole gets the engine defaultopenai:gpt-image-2.5-sunburst, and a step on a provider the engine builds no image model for (onlyopenaitoday) fails the call rather than being skipped. Withwidget: { widgetId, name }the image goes straight onto that open widget’s page as its own media (the answer{ widget: { widgetId, name, seq } }); otherwise it is written into the calling agent’s artifacts directory (agents/<agentId>/images/) and returned as a path (path,fileName,mimeType,sizeBytes— the same shape the download tools return), so the bytes never enter the transcript and the agent hands the file on withfile_send/telegram_send/artifact_publish; a provider answer with no image is a failure, never an empty success;moderaterisk with a per-minute rate limit, and a description that tells the model to refine the prompt rather than retry blindlyaudio_transcribe— wrapsMediaUnderstandingService.transcribeAudio({ url | path, language? }); thepathform accepts a local file, which is the transport past the Bot API 20 MBgetFilelimitimage_process—resize(maxSide, jpeg/png, quality),convert(HEIC→JPEG with EXIF rotate),optimize(PNG grid search to a byte budget, default 500 KB),info(dimensions, alpha, content-type, size)document_read— one reader for every document an agent is handed: takesurlorpath, detects the type from the bytes (extension and header as fallback) and dispatches — PDF throughextractPdfContent(capsmaxPages1–50,maxPixels2 000 000,minTextChars100, optionalpageNumbers; when the text is too thin it renders the pages and SHOWS them to the calling agent as images —modelMedia, never posted to the person’s chat; at most 10 pages a call within the media bound,imagePagesnames them andomittedImagePagesthe rest, read withpageNumbers), Word / Excel / PowerPoint / OpenDocument / RTF / EPUB / HTML throughextractOfficeTextas markdown, and txt / md / csv / json / xml / yaml as-is; text pastmaxChars(default 50 000, max 200 000) is cut with a marker and anextOffsetto continue from viaoffset. An image, audio/video file, legacy binary Office file or other binary is a failure that names the detected type and the tool to use instead (image_process,audio_transcribe). All parsing runs on worker threads, so a large file never stalls the process serving other tool calls; the file’s bytes move to the thread rather than being copied, so a near-cap document is one copy in memory
- Media Attachments End-to-End —
MediaAttachmentis the single representation carried unchanged from Telegram ingress through tool results, chat input, LLM message building, agent input, both agent boundaries (multipartUserContentfor API agents, file URLs appended to the prompt for CLI agents — each non-image entry names the tool that reads it,document_readoraudio_transcribe, and so does an API agent’s description of a file it cannot receive as bytes), stream UI finalize and persistence asAttachmentRefinconversation_events.metadata- Eager URL resolution — the Telegram bridge resolves
fileId→ URL at ingress, soMediaAttachment.urlis always populated by the time it reaches the domain layer and no lazyFileResolverinterface exists
- Eager URL resolution — the Telegram bridge resolves
- Web Content Extraction — first-party URL reading with Mozilla Readability, so an agent can read a page without an external extraction API; the parse (hundreds of ms of CPU on an ordinary news page) runs on a worker thread, off the event loop that serves every other tool call
- OpenClaw Port Contract — a documented transfer matrix for the upstream media modules: what copies 1:1 (types, MIME utilities, PDF/image/fetch primitives), what is adapted (credential-free request types, DI instead of a plugin registry,
RawMessageInputinstead of a 67-fieldMsgContext), and what is deliberately not transferred (the config God Object, the orchestrator, template interpolation) — plus the step-by-step procedure for absorbing a new upstream capability
Integrations
- Integration Kit (declarative) — a brain describes an integration as one object (tools + webhooks) and the engine registers it everywhere
defineIntegration— identity passthrough with runtime validation: name pattern^[a-z0-9-]+$, unique tool names, non-empty webhook paths without a leading slash, unique(method, path)pairs; violations raiseIntegrationValidationError- Registration fan-out —
registerIntegrationKitToolsruns in every app that builds aToolRegistry(webhooks,mcp,pipelines,worker,chat-core), so one declaration makes the tool visible on every surface - Provenance stamping — integration tools carry
source: 'integration', distinguishing them from'engine'and'project'in observability and in tool policy - Webhook mounting — each handler becomes
<METHOD> /webhooks/{name}/{path}with raw-body capture, optionalverifySignature, and error containment (a throw is a logged 500)
- Token Providers — one interface (
getAccessToken(subjectId)), two interchangeable implementations, invisible to toolsEnvTokenProvider— zero-infra: a prefixed env var, no refresh, no expiry;TokenUnavailableErrorwhen unsetDatabaseTokenProvider— per-subject lookup plus reactive refresh, wired automatically for every provider declared indefineProject({ oauthProviders })
- OAuth Connection Store — encrypted per-subject credential storage shared by the MCP surface and brain-declared providers
- Tables —
connections(exactly one ofmcp_server_url/provider_id, CHECK-enforced;mcp_oauth/static_bearer/no_auth;active/refresh_failed/revoked; archived rather than deleted) andoauth_clients(one DCR-registered or pre-registered client per authorization server) - Column encryption — AES-256-GCM with a
[version][iv][ciphertext][tag]blob layout and a version byte reserved for KMS migration; the key is a base64 32-byteCONNECTIONS_TOKEN_ENC_KEYvalidated at boot, and a decrypt failure raisesConnectionDecryptErrorafter incrementingconnection_token_decrypt_errors_total - Uniqueness — partial unique indexes on
(subject, mcp_server_url|provider_id, COALESCE(account_label,''))over non-archived rows, so a re-authorization revives the row in place instead of inserting a duplicate - Health filtering — token lookups use
liveOnly(not archived andactive), so a dead row never answers and can’t shadow a healthy one; display queries deliberately keep showing dead rows - Revocation — best-effort RFC 7009 upstream revoke with the outcome counted (
revoked/unsupported/failed/skipped), and an unconditional local archive so a row is never stranded - Subject namespaces —
tg:user:,tg:chat:,dash:,mcp:,cli:, withnullfor genuinely headless callsites; synthetic placeholder principals are forbidden
- Tables —
- Reactive Token Refresh — refresh happens just-in-time at hand-off, never on a background sweep
- Single owner —
refreshUnderLockserves both surfaces under a per-connection Redis lock (connection-refresh:{id}, 45 s TTL) with an in-lock re-read, so concurrent refreshes collapse into one upstream exchange - Hard-bounded exchange — a 20 s timeout stamped on the openid-client
Configuration, with lock ownership re-asserted by Lua CAS immediately before the refresh token is burned; a holder that lost the lock re-enters once rather than replaying the token, and two consecutive losses throw rather than return a corpse - Guarded persist on both paths — success and failure both CAS on the exact
refresh_token_encbytes that were exchanged; a CAS miss yields to the stored token and discards, never re-anchors, so a mid-flight browser re-authorization is not overwritten or stampedrefresh_failed - Ambiguous-exchange retry — a non-conforming response or a timeout may mean the AS already consumed the token, so the exchange is retried once immediately inside the same lock and a terminal second attempt is reported as
lost_rotation; a transport error (the request never reached the AS) is deliberately not retried - Per-provider terminal codes —
OAuthProviderConfig.terminalRefreshErrorslets a brain add e.g.invalid_requestfor Ory Hydra-fronted servers that answer off-spec, so a dead connection is recognized instead of retried forever needsRefreshrules — expiry inside the buffer refreshes; a null expiry with a refresh token refreshes once to establish a TTL; a null expiry with no refresh token never refreshes- Manual force-refresh —
forceRefreshConnectionruns the same locked path with an infinite buffer forconnection_renew(the dashboard’s “Refresh now”), guarding up front so a non-refreshable row is rejected rather than flipped torefresh_failed, and normalizing both surfaces’ errors into oneConnectionRefreshFailedError
- Single owner —
- Self-Reporting Connections — a connection whose refresh token dies announces itself by name in the status chat
- One writer, exactly once —
markRefreshFailedCASes the row and emitsconnection:refresh_failed; the corpse then fails every subsequent lookup, so a second attempt cannot re-announce - Notification, not HITL — nobody is blocked and there is nothing to answer, so the death is a chat message (
ConnectionHealthNotifier) naming the integration, the cause (invalid_grant/lost_rotation/no_refresh_token) and both remediations; the durable facts are therefresh_failedrow and theTokenUnavailableErrorre-raised on every later fetch
- One writer, exactly once —
- No-Ingress OAuth — a server with zero inbound ports can complete OAuth and refresh forever
- Why it works — an authorization server never fetches
redirect_uri; it 302s the person’s browser there. The code exchange and every refresh are outbound POSTs - Chat loop — an agent hitting
TokenUnavailableErrorcallshitl_request_connection, pauses on anoauth_consentHITL card, and the responder replies to the card with the (unreachable) redirect URL, which is forwarded verbatim to the internal callback - CLI loop —
omnislash connect provider|mcpsharesrunOAuthPasteBackFlow: print the authorize URL, prompt for the pasted address bar, validatestateandcodeXORerrorlocally, forward the query verbatim, and take the route’s status code as the verdict (falling back to/oauth/status/:hitlIdon a non-2xx) - A paste spends the link — the nonce is one-shot and the PKCE verifier and HITL binding are GETDEL’d on every path including errors, so a failure is reported with the AS’s own reason and an instruction to request a fresh card, never “retry that link”
- Real deadline —
STATE_TTL_MSis 30 minutes (a human loop through phone, login, 2FA and a dead landing page does not fit in 10), and an already-expired attempt fails immediately instead of after the paste - Two URLs, never conflated —
WEBHOOKS_PUBLIC_URLis theredirect_uristring a browser types (and the availability gate for the OAuth routes);WEBHOOKS_INTERNAL_URLis what sibling services dial
- Why it works — an authorization server never fetches
- Robot OAuth Authorization (
autoAuthorize) — a provider can hand the consent screen to a browser agent instead of a human- Flow —
/oauth/initalso enqueues anoauth-auto-authorizejob that spawnssystem:browserto open the authorize URL, log in, clear an emailed one-time code viaemail_list/email_get, approve consent, and report the final address-bar URL, which is then forwarded byte-for-byte like a paste - Credentials —
<PROVIDER>_LOGIN_EMAIL/_LOGIN_PASSWORDdeclared in the brain env schema and read inside the handler, never carried on the job payload - Skip and retry policy — silently skipped unless opted in with both credentials and
PLAYWRIGHT_MCP_URLset;attempts: 1, because the nonce, verifier and HITL binding are one-shot - Failure costs nothing — the human consent card is minted unconditionally, so every failure mode simply leaves the normal loop standing
- Documented blast radius — the reported redirect must carry this flow’s
state, so a subverted browser agent can only fail to complete the flow the engine opened; the password does outlive the run in the agent row, the Restate journal and the CLI transcript, and the run holdsemail:readon the mailbox
- Flow —
- MCP Catalog & Store — a browseable catalog of remote MCP servers with a two-click install
- Live mirror — an hourly idempotent sync of the official MCP registry, upserting every entry with a
streamable-httpremote by(source, namespace); archival only after a successful, non-empty pass, and a re-appearing namespace un-archives itself - A flaky page does not cost the pass — a 5xx, 429 or transport failure on one
/v0/serverspage is retried 3× (1s/2s/4s) inside the walk’s deadline and the caller’s signal; 4xx, envelope shape and cursor cycles still fail fast - Three sources —
official(registry mirror),custom(dashboard-pasted rows),brain-declared(seeded fromdefineProject({ oauthProviders })at startup, archived when removed from config) - Install routing —
integration_installbranches onsourcefirst thenauth_type:oauthreturns an authorize URL the browser navigates to,no_authupserts immediately;byotis the dashboard’s alone (the person pastes the token, it becomes astatic_bearerrow) — a secret never rides a tool’s input - Rich cards + keyset pagination — repository/website/icons/published-at columns, a
(published_at DESC NULLS LAST, id ASC)index, opaque base64url cursors, connected entries pinned to page 1,connectedcomputed by a single batched join againstconnections - Curation —
integrationsStore.mcpAllowedfiltersofficialrows by reverse-DNS namespace;brain-declaredandcustomare never filtered - Metrics —
mcp_catalog_sync_total{result}andmcp_catalog_entries_total{source,status}
- Live mirror — an hourly idempotent sync of the official MCP registry, upserting every entry with a
- Services & Connections as ops — every external service the deployment acts through as one row, with the same tools for an agent and the dashboard
- One row per service —
service_list/service_get: MCP servers and brain-declared OAuth providers (with their connections), integration bundles and Telegram user accounts, keyed<type>:<name>(theserviceref); each with its state in the engine’s words, its accounts (strict projection, never a token or a server URL’s query; a service whose only connection was revoked stays listed asrevoked, with the way back), the tools it brings (an MCP server’s: those its agents called in the window), which features called them over a window, its circuit breaker and its credential rows; since 1.25.0 each account’stype(mcp_oauth,static_bearer,no_auth,mtproto) on the list too, and an integration bundle’s declaredwebhooks(/webhooks/<bundle>/<path>) - Authorize / renew / revoke —
connection_authorizereturns the consent URL without waiting (the callback revives the row in place; an agent that must wait keepshitl_request_connection),connection_renewforce-refreshes from the refresh token,connection_revoke(integration:revoke, restricted, HITL) revokes at the issuer and archives - Sent as you — every send through a Telegram user account’s tools is an action-log row (
telegram.send, against the chat, attributed to the calling agent);service_list_messageslists an account’s sends with their text read from Telegram for the page, never stored
- One row per service —
- Pre-Registered OAuth Clients —
defineProject({ oauthClients })seedsoauth_clientsrows from env for authorization servers that don’t support RFC 7591 dynamic client registration, making them runtime-indistinguishable from DCR-issued clients;oauthCredentialResolverlets a brain source(clientId, clientSecret)from KMS/Vault instead of the${PROVIDER}_CLIENT_IDconvention - BYOT Admin API —
POST /api/connections/byot(bearer-authed,.strict()body) encrypts a pasted token into astatic_bearerconnection, idempotent by upsert so a re-POST revives an archived row - Google — Gmail, Calendar, Drive, Tasks, Sheets and Docs over one OAuth2 service
- Gmail —
email_list,email_get,email_send - Calendar —
calendar_list,calendar_list_events,calendar_create_event,calendar_update_event,calendar_delete_event(multi-calendar, full event CRUD with time guards; an event’scolorfrom Google’s 11-color palette,defaulton update resets it — it colors this calendar’s copy only, attendees keep their own, and a recolor alone emails no one) - Drive —
drive_list_files,drive_read_file,drive_download_file,drive_search,drive_upload_file,drive_create_folder,drive_delete_file- Binary files both ways —
drive_download_filestreams any file (video, PDF, image, archive) into the agent’s artifacts directory and returns its local path, exporting Docs/Slides as PDF, Sheets as XLSX and Drawings as PNG unlessexportFormatnames another;drive_upload_filetakes textcontentor afile_pathfrom the agent’s own artifacts directory (the same fence asfile_send/artifact_publish). Transfers stream from and to disk, so size is bounded by Drive, not memory, and end on cancel or a stalled connection, never on a total-time cap - Text reads stay text —
drive_read_filereturns Docs as plain text, Sheets as CSV and text files as-is, and fails permanently on a binary file, pointing atdrive_download_file
- Binary files both ways —
- Tasks —
tasks_list_lists,tasks_list,tasks_create,tasks_complete,tasks_update,tasks_delete - Sheets —
sheets_read,sheets_write,sheets_append - Docs —
docs_get,docs_create,docs_append,docs_search,docs_replace
- Gmail —
- Telegram MTProto — a full user-account client alongside the Bot API
- Tools —
telegram_accounts,telegram_account_status,telegram_account_check, plus one family per domain:- Send —
telegram_send(text, one file, a 2–10-file album, voice / round-video note; reply with an exactquote, forumtopicId,parseModemarkdown|html,silent,noLinkPreview,scheduleAt),telegram_scheduled(list / delete / send-now a chat’s scheduled queue — entries are thetelegram_historymessage shape withdate= when it will be delivered; scheduled ids are their own id space; send-now returns the new chat ids) - Messages —
telegram_history,telegram_search,telegram_export,telegram_download_media,telegram_press_button(refuses a message from the engine’s own bots — their buttons are owners’ controls, and a press through the owner’s account would read as the owner’s hand);telegram_get_messages(by ids, or by at.me/<user>/<id>/t.me/c/<id>/<id>link — the link’s chat is what a business chat’s scope checks),telegram_replies(channel-post comments or a group reply thread, plus the discussion chat and root id to comment on),telegram_read_status(read or not from the chat’s read marker; when, in private chats; who and when, in small groups — a detail Telegram withholds is named indetailUnavailable); on existing messagestelegram_edit_message,telegram_delete_messages(ids are checked against the chat first;forEveryonein private chats and basic groups, always for everyone in channels),telegram_react(set / remove, emoji or custom emoji),telegram_pin_message(pinned,onlyForMe,silent,allto unpin every message),telegram_mark_read(history / mentions / reactions badges),telegram_save_draft(empty text clears) - Dialogs —
telegram_dialogs(each chat’s unread / mentions / manual-unread mark, muted, pinned, archived, outbox read marker and draft (ISOdate);unreadOnly,archivedtrue = Archive only / false = main list only,folderId— unread and folder filters scan the list client-side, so page with the returnednextOffsetDate),telegram_archive_chat(archived),telegram_mute_chat(muted, optionaluntilas ISO-8601 with offset, otherwise forever),telegram_mark_unread(the manual mark only — messages keep their read state),telegram_pin_dialog(pinned),telegram_folders(list / create / edit / add_chat / remove_chat; edit lists replace, removing a chat from a folder with type rules also excludes it; shared folders are read-only). The list and folders are account-wide (refused from a business chat); the per-chat settings act on one chat - Contacts & info —
telegram_resolve_user(@username, phone,t.me/<username>, an invite link — previewed viacheckChatInvitewithout joining: title,chatType, members, admin approval, the chat id if already a member — or a message link — the marked chat id + message id + topic; the result’skindsays which),telegram_contacts(the contact book sorted by name withoffsetpaging, or aquerysearch — your contacts and chat partners first, then global public usernames withisContact: false; people only),telegram_add_contact(add or rename by@username/ user id, or byphone— finds the account registered to the number,CONTACT_NOT_FOUNDwhen none;sharePhoneshows them your number),telegram_delete_contact,telegram_block(user+blockedtrue/false, or nouserfor the block list),telegram_user_info(users.getFullUser: bio, last seen — an exact time only when the user shares it —, birthday, business hours asmon 09:00in the business timezone / location / intro, personal channel, common-chat count, contact / mutual / blocked / scam flags),telegram_chat_info(group or channel: description, member / online / admin counts, linked discussion chat, pinned message, invite link (admins only), slow mode, forum flag),telegram_common_chats(cursor paging withnextAfterChatId). Every chat these tools name is one shape —{ chatId, chatType, title, username, isBot },chatTypebeingprivate | group | supergroup | channellike the inbound events’ and hook filters’. The contact book, block list, common chats, phone import and non-message-link lookups are account-wide (refused from a business chat); user info, block / unblock, add / delete by user and a message link act on that one chat, so a business chat may apply them to its own counterpart only - Groups & channels —
telegram_participants;telegram_join(by@username/t.me/<name>or invite link; a chat that screens joiners returnsstatus: requested),telegram_leave,telegram_create_group(chatTypegroup | supergroup | channel,about,forum,members— refused members come back innotAdded; a member failure after creation is reported inmembersError, never by re-creating),telegram_invite_members(per-useradded/notAddedwith Telegram’s reason — a privacy refusal Telegram reports silently, too),telegram_ban_member(kick/banwith optionaluntil/unban; basic groups have no ban list —kickonly),telegram_invite_links(create with expiry / join limit / admin approval, list this account’s links, revoke — revoking the primary returns its replacement),telegram_forum_topics(list / create;topicIdfeedstelegram_sendandtelegram_history). Join and create are account-wide (refused from a business chat); the rest act on one chat. An upgraded basic group’s old id resolves to its supergroup - Raw MTProto —
telegram_invoke_read/telegram_invoke: the escape hatch for any method no dedicated tool covers.methodis the TL name from core.telegram.org (messages.getFullChat),paramsits snake_case fields (camelCase accepted); longs go as decimal strings, peer fields take@username/ chat id /meat any depth, other objects{"_": "<constructor>", …}. An unknown method or field fails offline (UNKNOWN_METHOD/INVALID_PARAMS, with did-you-mean) before any rate-limit budget is spent.telegram_invoke_readadmits onlyget*/search*/check*/resolve*methods, minus the read-named ones that write (contacts.getLocated,messages.getBotCallbackAnswer,messages.getMessagesViewswithincrement) —NOT_A_READ_METHODotherwise; both refuse the engine denylist no brain can lift — account takeover or loss, identity disclosure and money:auth.*,payments.*,smsjobs.*,account.deleteAccount,account.resetAuthorization(s),account.resetWebAuthorization(s),account.updatePasswordSettings,account.resetPassword/account.declinePasswordReset, the 2FA recovery-email flow (account.confirmPasswordEmail/resendPasswordEmail/cancelPasswordEmail), the login email (account.sendVerifyEmailCode/account.verifyEmail),account.sendChangePhoneCode/account.changePhone,account.setAccountTTL/account.setAuthorizationTTL,account.initTakeoutSession, Telegram Passport writes and disclosure (account.acceptAuthorization,account.saveSecureValue/deleteSecureValue),messages.sendPaidReaction, and any call withallow_paid_stars(INVOKE_DENIED) — and both are refused from a business chat. The reply comes back as JSON (classNamekept, longs as strings, bytes removed) capped atmaxChars(engine default 20000, per call up to 200000) with atruncatedflag; own messages a raw send delivers are announced to the inbound listener’s loop guard like any send. Own rate bucketsinvoke_read(20/min) andinvoke(5/min, 100/day)
- Send —
- Capabilities — reads are
messaging:read/contact:read; sending ismessaging:send(approval); edit / delete / react / pin / mark-read / archive / mute / drafts / folders / scheduled aremessaging:manage; contact-book changes arecontact:write; join / leave / create / invite / ban / invite links / forum topics areaccess:admin(approval); raw calls aretelegram:invoke_read(read-prefixed methods) andtelegram:invoke(approval) - Send or forward —
telegram_sendeither sendstextand/orfilesor, givenforward: { fromChatId, messageId, dropAuthor? }and an emptytext, forwards an existing message so its media, caption and clickable links arrive intact with no download/re-upload; a forward draws on the samesendrate-limit bucket; a source that is gone or locked fails permanently (MESSAGE_ID_INVALID/CHAT_FORWARDS_RESTRICTEDfrom Telegram,MESSAGE_NOT_FOUNDwhen Telegram returns no forwarded message), and a source chat that does not resolve fails asFORWARD_SOURCE_UNAVAILABLE— never under a code that reads as the recipient’s. From a business (secretary) chat both the destination and the source must be that conversation - Sends are on the record — every message a user account puts into a chat through
telegram_send(a send or a forward, not a scheduled one) ortelegram_scheduled’s send-now is one action-log rowtelegram.sendagainst the chat (tg:chat:<id>), attributed to the calling agent with{ account, messageId }— never the text;telegram_sendanswers the chat’s marked id (chatId) beside the message ids. “Sent as you” (service_list_messages) andaudit_listread them - A limited account is engine state (since 1.27.0) — a call that fails with
PEER_FLOOD, or a send whoseFLOOD_WAITreachestelegram.accountLimit.floodWaitMinutes(60 by default), records the accountlimited { reason, since, until? }before the failure returns, in whichever process sent;telegram_account_status(messaging:read) and the pipeline SDK’stelegramAccountStatus(ctx, account?)read it, so a pipeline gates cold sends on it instead of keeping its own flag (telegram_senditself is not refused — a limited account may still answer whoever wrote first). The worker asks @SpamBot (/startto the verified bot only, never a stranger; at once, then everycheckEveryHours, 24 by default, or at the release date it named), lifts the limit on a recognised “no limits” reply (English and Russian; an unreadable one changes nothing and is kept for a person), tells the status chat when a limit outlivesalertAfterDays(2 by default) and when it lifts; aFLOOD_WAITlimit ends at itsuntil, unannounced.telegram_account_check(messaging:manage) asks now, or withclear: truelifts it by hand (telegram-account.md § When Telegram limits the account) - Multi-account pool — accounts live in
omnislash.mtproto_sessions(the source of truth, queried on every read), while the pool caches liveTelegramClientsockets per process and invalidates them cross-process over the event bus; QR-code authorization with a validated account-name format - Per-account, per-operation rate limits — conservative baselines keyed
telegram:<account>:<op>so an over-limit account never blocks an under-limit one (send 20/min·1000/day, global search 5/min, export 3/min, media download 10/min, …) - Durable media download — serial part-wise download through a durable file writer
- Inbound events —
mtproto:message.received/.edited/.deleted/.read(+mtproto:gap) for what the user account sees, consumed by ordinary hooks (telegram-account.md). The listener is derived, not configured: it runs for every account some enabledmtproto:message.*hook can match, one owner per account across worker replicas (mtproto-listener:<account>lease); an account no hook wants any more has its update state forgotten, so a hook enabled later starts fresh instead of replaying the pause. Only updates some hookfilteraccepts are kept. The message part is thetelegram_historyshape plussender,chatType,outgoing,mentionsMe,topicId,sentByAgent(loop guard: every engine send is recorded inomnislash.mtproto_sent_messages; a fresh outgoing message the record has not reached yet is looked up once more),fromScheduled(delivered from the scheduled queue) andsecretaryBound(the private chat is also served by a replying secretary bot). Every sender-authored field (text, display name, sender, file name, contact, buttons) renders fenced in a hook prompt. Gap-free: the update state (pts/qts/date/seq+ per-channel pts) is kept inmtproto_sessionsand advanced only after the event is durable; restart, reconnect, lease handover and 15 quiet minutes catch up withupdates.getDifference/getChannelDifference; a channel first heard of after downtime is read back to the last sync; a first run starts fromupdates.getState; a range Telegram refuses to replay is published asmtproto:gap. Exactly-once: each event carries its permanent key asid(<account>:<chatId>:<messageId>:<kind>), is written toexternal_events(sourcemtproto) under it before the state moves, and the row’s route job hands it to the hooks keyed by the row — a replay is a duplicate at the write and fires nothing; a row whose routing never started is re-routed by the inbound sweep. A handled event’s payload (the counterpart’s message) is emptied aftertelegram.inbound.payloadRetentionDaysinconstitution/MANIFEST.yaml(engine 7 days); the key stays. Own rate bucketupdates(60/min).omnislash doctor(MTProto hook accounts) warns about a hook no authorized account can feed
- Tools —
- Anysite — one API key behind the social, video and public-data scrapers (28 tools)
- Web —
duckduckgo_search,web_parse,web_sitemap - YouTube —
youtube_search,youtube_video,youtube_subtitles,youtube_channel - Twitter/X —
twitter_search,twitter_user,twitter_user_posts - LinkedIn —
linkedin_search_users,linkedin_profile,linkedin_search_companies,linkedin_company,linkedin_search_posts,linkedin_search_jobs - Instagram —
instagram_user,instagram_user_posts,instagram_post,instagram_search - Reddit —
reddit_search,reddit_post,reddit_comments - SEC filings —
sec_search,sec_document - YCombinator —
yc_search_companies,yc_company,yc_search_founders
- Web —
- Notion —
notion_search,notion_databases,notion_query,notion_create_page,notion_update_page,notion_delete_page,notion_find_duplicates, with property helpers and response caching - Figma —
figma_file,figma_node,figma_images,figma_components,figma_styles,figma_comments,figma_add_comment,figma_projects,figma_project_files - Perplexity —
perplexity_searchandperplexity_factcheck, AI search with sources and a response cache - Outscraper —
maps_search,maps_reviews,outscraper_emails - n8n —
n8n_workflows,n8n_workflow,n8n_trigger,n8n_executions,n8n_execution,n8n_activate,n8n_deactivate,n8n_stop_execution; the optionaln8nservice ships with its own database, an init container and a pre-seeded “Omnislash MCP Agent” workflow wiring n8n’s AI Agent to the engine’s MCP server - ElevenLabs —
elevenlabs_transcribe(diarization, word timings, speakers),elevenlabs_voices(with each voice’scategory, which is how a brain verifies a voice id its plan can actually reach),elevenlabs_sound_effect - Web Search —
web_searchover a provider set that is always registered: Jina and DuckDuckGo are free baselines, Tavily joins when its key exists, behind the shared circuit breaker and rate limiter - Web Fetch —
web_fetchreads a first-party URL with Mozilla Readability extraction and a visibility check, no external API needed - Media Tools —
document_read(PDF, office, OpenDocument, RTF, EPUB, HTML and text → text) andimage_process(resize / convert / optimize), always registered with no external service dependency - OpenAI Media —
image_generate(image generation), always available sinceOPENAI_API_KEYis required; the model chain is theimagerole inmodels.json - Multi-Provider Transcription —
audio_transcriberunsMediaUnderstandingServiceover a brain-declared chain (transcription.chain, defaulting to Groq → OpenAI → Deepgram → localwhisper-ctranslate2), accepting a URL (SSRF-guarded fetch) or a local path (for files past the Bot API 20 MB cap), with size floors, ceilings and cancellation- No single key is load-bearing — a step whose API key or binary is absent is skipped at boot, and the terminal
whisper-ctranslate2step needs neither
- No single key is load-bearing — a step whose API key or binary is absent is skipped at boot, and the terminal
- Speech Synthesis (TTS) — a brain-declared provider chain decides which voice the brain speaks in, for both
voice_render(renders: onto a widget’s page withwidget, else a file for an agent and base64 for a caller with no agent) andvoice_send(synthesizes and delivers)- The chain —
speech.chainis an ordered list of steps, each naming its own provider-native voice; the first step that answers wins, a step whose API key is missing is skipped with a warning, and a failing step falls through. Omittingspeechgives ElevenLabs → OpenAI with stock voices openaistep —gpt-4o-mini-ttswith 13 named presets and free-textinstructionsfor delivery direction; pay-as-you-go, so no allowance cliffelevenlabsstep —eleven_flash_v2_5with a voice id (default the stock premade River); a Voice Library voice on a free plan (402) or an exhausted credit balance falls through silently, which is why the id should be verified before committing it- Declaring a chain means declaring all of it — the engine appends no hidden last resort, so a one-step chain that fails is silence by choice
- Delivery is awaited —
voice_sendblocks on the notification worker’s result and returns the deliveredmessageId, so a failed delivery is a failed tool call rather than a confident “sent”
- The chain —
- Notification Channels — named, brain-declared delivery destinations that agents can use without approval cards
- The model —
notifications.channelsdeclares a public name + an LLM-facing description in the committed config; the private chat id resolves at boot from a fixedNOTIFY_<NAME>_CHAT_IDconvention that is never declared - Synthesized env entries —
loadProjectConfig()generates each channel’s env-schema entry (hand-declaring it fails the load), so an unset id fails boot validation, appears in the dashboard catalog under Notifications, and is required by the prod deploy check - Two reserved channels —
status(TELEGRAM_STATUS_CHAT_ID) andowner(the primary owner’s DM — the firstTELEGRAM_ALLOWED_IDSentry, numeric by validation; never the HITL approvals chat);ownerdrops out of the enum on a brain with no Telegram surface notification_send— an optional, closed, config-derived enum of destinations (omitted → the turn’s own chat, headless →status, same chain ascontext.notify), markdown by default with safe degradation and automatic chunking, delivery confirmed by returning the landedmessageId- Why there is no chat-id form — the
notification:sendcapability is admissible ashitl:falseonly because the destination is never free-form; a raw chat id belongs tomessaging:sendand needs approval, which is also whyqueue_create_jobexposes neither theui-notificationsnor theworkflow-executionlane
- The model —
context.notify— delivery for brain code — every tool body receives a guaranteednotify(req)on itsToolExecutionContext, injected byToolsGatewayat the same seam asgetServiceToken- Addressing — by channel name or by the calling turn’s own surface (persona included), never by chat id, so the
hitl:falsestory is unchanged; a headless turn without a target lands onstatus - Payloads —
{ text, parseMode }, a nativesticker: { fileId }, andreplyToMessageIdthat degrades to unthreaded rather than failing - Stickers are turn-surface only — platform
file_ids are bot-scoped, so a sticker with an explicit channel, or from a headless turn, is a validation error rather than a retried failure - Blocks until delivery — which is the point: “sent, therefore log it” needs no compensating logic. A malformed request is a permanent error; a failed delivery is transient
- One payload kind per call —
body+stickerride one job retried whole, so a sticker failure would re-send the text
- Addressing — by channel name or by the calling turn’s own surface (persona included), never by chat id, so the
- LLM Provider Surface — the engine wires five AI SDK API providers directly —
openai,anthropic(CLAUDE_API_KEY),nvidia(custom base URL) and two aggregators,openrouterandvercel(Vercel AI Gateway,AI_GATEWAY_API_KEY) — each fronting hundreds of models for both API agents and CLI harnesses; the concrete model registry, its chains and pricing are brain-owned (constitution/models.json)- An unconfigured provider disappears quietly — its models are dropped from the registry and its steps from every chain, with no log line, so a chain silently gets shorter rather than failing; a role whose chain empties entirely is what surfaces the problem, and
omnislash doctoris where to look for it
- An unconfigured provider disappears quietly — its models are dropped from the registry and its steps from every chain, with no log line, so a chain silently gets shorter rather than failing; a role whose chain empties entirely is what surfaces the problem, and
CLI
- Single Entry Point —
omnislash(citty) is the only interface a brain developer needs, run from the brain root or from the engine root withBRAIN_ROOT=<path>- Lazy subcommands — each command is an import thunk, so
--helpand light commands never pull in drizzle/redis/agents-core - Error presentation — commands throw
OmnislashError(stable code + actionable hint); citty usage errors print a hint instead of a stack, everything else prints in full.--verboseraises log level - stdout is the command’s output, never a log line — the entrypoint pins the engine’s pino destination to fd 2 before any subcommand (and with it any engine package) is imported (
packages/cli/src/log-destination.ts, same mechanism as the MCP--stdioguard), soTOKEN=$(omnislash git token owner/repo)captures the token alone and… --json | jqgets one parseable document. Diagnostics are kept — they land on stderr - No global install required — works through
npx/pnpm dlx, and is baked into the runtime image at/usr/local/bin/omnislash(plus anomnishorthand) so it can bedocker compose exec’d in production - Upgrade notice —
dev,startanddoctorcheck the registry and announce a newer engine release
- Lazy subcommands — each command is an import thunk, so
omnislash init [dir]— empty directory in, runnable committed brain out- Flags —
--template chat-only|scheduled-ops|pipeline-heavy,--name,--version(engine pin, defaults to the CLI’s own),--telegram(declare a bot persona, which is what makes theTELEGRAM_*trio required),--no-example(skip the starter weather skill),--no-install,--no-git,--force(non-empty dir),--yes(non-interactive) - One linear sequence of side effects — recommended-defaults gate → env wizard → scaffold →
.env+.env.example→git init→pnpm install→ engine artifacts via the brain’s own spawnedregen→git commit --no-verify; nothing is written until every input is accepted, and each step leaves a valid tree behind if it fails.--no-gitgoverns the commit as well as thegit init, so a directory the author already made a repository is never swept into an engine-authored commit - Env wizard — asks only for
prompted+ required variables this brain’s shape actually needs, harvests from the surrounding environment exactly that same set (never wider — abots: []brain must not inherit a sibling brain’s liveTELEGRAM_BOT_TOKEN), masks every answer except the Telegram id fields (echoed so a typo is catchable), and lets every one of them be skipped;WEBHOOK_SECRETand the twoCONNECTIONS_*keys are minted rather than asked for. Secrets have no flag twin on purpose — argv reaches shell history andps - Owners are numeric ids —
TELEGRAM_ALLOWED_IDStakes numeric Telegram user ids only (a@usernamecan be re-registered by someone else, so validation refuses it and names@userinfobot); the first id is the primary owner, who doubles as the HITL destination unlessTELEGRAM_HITL_CHAT_IDnames another chat, and gets theownernotification channel and restart DMs - Ends on one command — the summary names what is still missing and prints exactly
cd <dir> && omnislash start
- Flags —
omnislash dev— engine-developer mode: infra in Docker, apps astsx watchchild processes- Flags —
--inspect(Node inspector),--skipDb(skip migrations),--only bot,worker,… - What it does — resolve brain root → load config + walk-up
.env→ bring uptemplateDirs: ['infra'](postgres, redis, prometheus, optional restate/playwright/browser/n8n/nocodb) → run migrations via the samedb:migratecode path as production → spawn every service with adevEntryinSERVICE_REGISTRY - Sidecar readiness gate — the dashboard’s Vite sidecar waits on the Hono server’s health URL (any HTTP response counts) with a 30 s warn-and-continue timeout, per-app so a slow service never blocks the others
- Graceful shutdown — first SIGINT/SIGTERM waits for children (10 s, then SIGKILL) and
composeStopis guaranteed to run exactly once viafinally, so infra is never orphaned
- Flags —
omnislash start— brain-developer mode: everything in Docker, brain root bind-mounted for hot reload- Flags —
--skipDb(drop thedb-migrateservice from the generated compose),--noCache(build without Docker cache) - Preflight order — Docker readiness FIRST (a stopped daemon must be reported as itself, not as whatever the next check notices), then config +
.env, thenrunBrainPreflighton the peer-dep shape /.npmrc/ config — all beforedocker buildburns minutes - Health gate is brain-tunable —
docker compose up --waitruns understartup.waitTimeoutSec(engine default 180s, raised per brain inomnislash.config.ts), long enough to outlast the scheduler container’s ownstart_periodon a cold first run - Connection map — one shared renderer with
omnislash dev, printing only endpoints that actually came up, with the dashboard’s agent chat (Chat) called out as the single next step: the one surface where a fresh brain talks to its agent with no Telegram and no external account
- Flags —
omnislash build— regenerates the brainDockerfile+.dockerignorefrom the pinned CLI version (or usesDockerfile.override) and builds the brain image on top of the engine base;--noCacheavailableomnislash compose— generates compose + monitoring + helper configs under.omnislash/;--prodselects the production templates,--image <img>sets the brain image tagomnislash stop/down/reset/clean— environment lifecyclestop— pause containers, preserve state;down— remove containers + networks, keep volumes;reset— down plus volume removal, with an explicit data-loss warning; all three take--mode dev|startand iterate every resolved compose contextclean— removes.omnislash/,node_modules/,.turbo/,coverage/(engine monorepo only)
omnislash logs [service]— container logs with--follow/-f,--tail/-n(default 100),--modeomnislash sync— runs the scheduler once (--once) for a forced manifest reconciliation; engine-source-only, resolves DB/Redis from the running composeomnislash doctor— the full environment health checklist (~58 checks across Machine / Project / Secrets / Infrastructure — the four questions in the order an author acts on them), exit code 1 on any failure and never on a warning;--check <id>runs one check by its stable id (llm-providers,upgrade-in-progress, …), so an upgrade entry’s Verify can cite one, or a narrow CI scope —envvalidatesprocess.envagainst the schema with no project root,pipelinesimports and validates every enabled pipeline module (and fails, never skips, on a missing project root or a config that does not load),enginechecks the installed engine is whole; an unknown--checkvalue prints the valid list and exits 1 instead of running the full report;--jsonprints the results as one array of{ id, name, status, message, hint, fix }- A mechanical remedy is printable — a failure branch that knows the command that fixes it returns
fix.command, rendered on its own line under the hint (a branch with no mechanical remedy carries the hint alone —fixis per-branch and optional, which is why it lives on the result and not on theCheck). The dashboard renders the same string and executes nothing --fix— applies the subset the CLI can perform itself (a closedDoctorFixActionunion, so an unhandled member is a compile error), then re-runs the whole report: the second verdict is the answer, and a fix that did not help shows up as a check that is still red. A baredoctorwrites nothing at all- A missing CLI harness is a warning, not a failure — it names the roles that asked for
claude/codex/opencode, so a brain answering on its API chains is not told it is broken
- A mechanical remedy is printable — a failure branch that knows the command that fixes it returns
omnislash install— installs missing skill binary dependencies from eachSKILL.mdinstall[]spec (apt / uv / node / brew / go);--yesskips the prompt,--dryRunprints the plan, 5-minute per-item timeoutomnislash connect— manage MCP and brain-declared OAuth provider connectionsconnect provider <id>— the no-ingress OAuth bootstrap for adefineProject({ oauthProviders })entry; always authorizes asDASHBOARD_SUBJECT(dash:default, deployment-wide), no--subjectflagconnect mcp <url> --subject <subject>— same paste-back flow for an MCP server; per-subject, so--subjectis mandatory under--prod--prod— re-invokes the same command inside the productionwebhookscontainer overssh -t+docker compose exec(TTY end to end), usingPROD_SERVER_HOST+PROD_PROJECT_DIRconnect revoke <id>— archives the connection row with a best-effort upstream RFC 7009 revoke
omnislash db— the migration and inspection surface, one code path shared withpnpm -C packages/infra db:*and thedb-migratecontainerdb migrate— apply pending migrations, engine line then brain; the only command any automatic path runsdb status— read-only per-line[✓] tag/[ ] tag (pending); creates nothing, not even the journaldb generate [--name X] [--engine]— diff the TypeScript schema against the line’s head snapshot and writeNNNN_name.sql+ snapshot + journal entry; opens no connection.--engineis refused outside the engine monorepodb baseline --force— stamp every missing migration as applied without executing it; refuses to run without--forcedb push— local-dev-only schema push, on no automatic path, warns when a journal existsdb studio— Drizzle Studio browser--mode dev|start— used only to derive compose credentials whenDATABASE_URLis absent
omnislash git— diagnostics for this deploy’s GitHub App- Preconditions checked once, in one wording — a resolvable Postgres DSN for every subcommand (
DATABASE_URLor thePOSTGRES_*components — the default managed-postgres deploy leavesDATABASE_URLunset, so the group never gates on that one variable), plusCONNECTIONS_TOKEN_ENC_KEYfor the subcommands that decrypt the App private key. The failure hint says what THAT command needed it for git installations— the accounts and orgs the App is installed on (login, type, repository selection, status),--jsonfor the raw rowsgit token <owner/repo>— mint a short-lived installation token scoped to that one repository; the bare token goes to stdout andexpires_at=… installation=…to stderr, soTOKEN=$(omnislash git token owner/repo)captures exactly the token (--jsonprints{ token, expiresAt, installationId, repository }instead). Uncached on purpose — a diagnostic must not populate or consume the runtime cachegit repos— the attachedcode_reposrows first (slug, remote,sync_status, last sync,last_error), then every repository the deploy’s installations can reach (GET /installation/repositories, paged), marked with the slug it is attached under. The rows print even with no App registered and no live installation — that section is skipped, not a failure;--jsonfor both halvesgit attach <owner/repo> [--slug <s>] [--branch <b>]— write acode_reposrow: slug defaults to the repo name normalized to the slug charset (toRepoSlug— lowercased, every other character run collapsed to-, soMy_Repo.js→my-repo-js; the same default the dashboard’s attach dialog shows), is validated against^[a-z0-9][a-z0-9-]*$and may not collide with the brain repo; the installation is resolved from the owner (provenance — the mint re-derives it per operation), and--branchis what both clone paths pass togit clone --branch, an omitted one meaning “ask the remote”. Re-attaching a slug re-points the single row rather than inserting a second; the old checkout is left on disk and the sync passes record theoriginmismatch instead of pulling itgit detach <slug>— drop the row. The/repos/<slug>checkout is left on disk; nothing reads it once the row is gone
- Preconditions checked once, in one wording — a resolvable Postgres DSN for every subcommand (
omnislash hooks trigger <id>— fire a hook manually for testing, with--payload <json>template variables and--eventto override the declared event nameomnislash pipeline— Restate pipeline operationspipeline run <name> [input]— trigger an execution, with--target-key,--idempotency-key(default random UUID) and--handler(defaultrun)pipeline status --name <n>— check execution status by idempotency/target key and handlerpipeline reconcile— drain removed services, sync terminal status and flag orphanedpipeline_invocationsrows;--dry-runby default,--applyto mutate,--jsonfor the full report
omnislash workflow reconcile— re-driveworkflow_runswhose Restate attempt is gone and parked runs with no waker left;--dry-runby default,--apply,--jsonomnislash replay [dirs...]— replay recorded JSONL fixtures into a fixture Postgres + Redis- Flags —
--reset(TRUNCATE + FLUSHDB, refuses the primary DB, requires--yes),--loop,--speed N(Infinity= no sleep),--inspect(manifest + per-kind counts, no replay),--pick(checkbox TUI over--root, default./fixtures),--plan FILE(YAML compose plan) - Composition — positional dirs accept a per-fixture
@<offset>(+2m,30s,500ms,1.5h); exactly one of--plan,--pickor positionals may specify fixtures
- Flags —
omnislash skills— skill store management- Subcommands —
add <url> [--as] [--force],install(restore fromskills/.lockfile.json),update [name],remove <name>,fork <name>(copy an engine skill into the brain’s own root),enable <name> [--all],disable <name>,check(lockfile vs disk drift, exit 1 for CI),list,measure-size [--only] [--dry-run] [--keep-tmp]
- Subcommands —
omnislash upgrade [version]— bumps every@omnislash-ai/*peer to one exact engine version, re-resolves the lockfile, regenerates every engine-owned artifact, syncsdocs/engine/andskills/engine/, writes the agent taskupgrade/TASK.md, and offers to commit- Refuses a contract-violating manifest — any
@omnislash-ai/*outsidepeerDependenciesaborts the run - Cross-version bridge — regeneration and the task are delegated to the freshly installed CLI’s public
regenandupgrade-plansubcommands, because only the new CLI carries the new templates and upgrade entries - Agent task — plans (current pin, target] (
--fromoverrides) intoupgrade/, see Agent Migrations; with a task written the commit is not pushed, since a push deploys - Ending contract — no-op success, hard failure (non-zero, so scripts and agents see it), or a best-effort git tail that never affects the exit code
- Refuses a contract-violating manifest — any
omnislash upgrade-plan --from <v> --to <v>— writes the agent task of a range from this CLI’s upgrade entries and prints it, merging into a task in progress (ticks and working files kept);no agent stepswhen the range has noneomnislash regen— rewrites every engine-owned artifact from the installed engine, unconditionally, so it also heals same-version corruption thatupgrade’s no-op check cannot see; refuses to run when the running CLI’s version differs from the installed one- Engine block — rewrites only the text between
<!-- BEGIN/END:omnislash-engine -->inCLAUDE.md(and a realAGENTS.md): where the engine docs are, how to upgrade and diagnose - Plans an upgrade an older CLI drove — no task yet and the committed pin older than the installed engine → writes the agent task for (committed pin, installed]
- Engine block — rewrites only the text between
omnislash init-server <server-ip>— a fresh Debian or Ubuntu host (amd64 or arm64) in, deployable server out, run from the brain root with no clone of the engine repo- Flags —
--project(default: the brain’s name),--ssh-user(defaultroot),--app-user(defaultomnislash),--swap(GB,0leaves the host’s swap untouched),--bot-mode polling|webhook,--webhook-port,--domain,--no-github,--yes - Derives what it can, asks only for the rest — the project name and the bwrap-isolation decision come from
omnislash.config.ts(a brain that doesn’t bwrap leaves the host kernel’sapparmor_restrict_unprivileged_usernsalone), the bot mode defaults topollingand is a flag rather than a question, and the one thing no brain file holds — the Infisical machine identity — is read from the environment or taken through a masked prompt, never through a flag, because argv reaches shell history andps - The remote half is a real
.shasset, not a TS template literal (a\033inside a JS string silently stops being an ANSI escape), streamed overssh … bash -sbehind a prelude of shell assignments whose every value is validated against a closed charset before it is quoted. Each step is idempotent —ssh-keygenunder[ -f ], swap underswapon --show, docker undercommand -v, andauthorized_keysmerged key-by-key rather than copied over (the app user’s file is the union of the admin’s key and the CI deploy key, written by two different steps) — so re-running it on a live host converges instead of breaking it - Nothing is installed from a stream nobody checked — Docker and Node.js come from the vendors’ own apt repos, and their signing key is refused unless it is the single key whose fingerprint the engine pins; the Infisical CLI is a release tarball checked against a per-architecture sha256 pinned the same way (no
get.docker.com | sh, nosetup_24.x | bash, nonpm install -g @infisical/cli— that package’s preinstall fetches the same tarball and verifies nothing). That is what narrows the supported host set, so a host outside it is refused up front, naming what to install by hand; one that already carries all three is provisioned anyway - Grades the host before you confirm — a read-only SSH call reads
nproc, MemTotal and the root filesystem’s size; the plan prints them and how they measure against the minimum server the production limits are sized for (4 vCPU / 8 GB / 80 GB): below it, the overrides to set before the first deploy; well above it, what can be raised (operations.md § Server size). The same grade as the doctor’sHost resources, and a warning, never a refusal - The private deploy key is never printed — the bootstrap streams to the terminal uncaptured, then a second silent SSH reads the key material and hands the private half straight to
gh secret seton stdin. It reaches no scrollback and no CI log; secret values never touch argv, whichpsshows to every user on the machine - No GHCR credential on the host — the host never pulls the engine base image (its layers ship inside the brain image, built in CI), and the brain’s own image is authenticated per-deploy by
deploy.shwith the workflow’s automaticGITHUB_TOKEN(whose cleanup trap logs out again), so a bootstrap-time login would be both redundant and erased by the first deploy. One PAT fewer in the server’s history - Reads the host key back with the deploy key, over the same silent channel, and pins it as
DEPLOY_HOST_KEY; the summary prints itsSHA256:fingerprint for an out-of-band check - GitHub handoff, best-effort by contract — sets the three
DEPLOY_*variables, the three secrets it holds and the brain-sync write deploy key, then prints the exactghcommand for every name only you can supply. A missinggh, a declined prompt or a 403 prints the manual path and returns: the host is already up, so a failed handoff must never read as a failed bootstrap - The required names come from the workflows themselves —
secrets.NAME/vars.NAMEextracted fromdeploy.yml+ci.ymlminus the automaticGITHUB_TOKEN, a||fallback marking a name optional. Which copy is read follows ownership: the generated text while the engine owns the file, the committed file once a.github/workflows/<name>.yml.overridesentinel hands it over (and nothing at all when its owner deleted it) — the same sentinelensureDeployYml/ensureCiYmlhonour when they decline to write. The handoff and the doctor’sGitHub Actions configcheck read the same extraction, so neither can drift from the workflows it is about, and a brain that deploys some other way is never red-lined for names its own workflow ignores .envlearns where this brain deploys —PROD_SERVER_HOST+PROD_PROJECT_DIRare upserted in place (existing keys edited where they stand, comments and order untouched, mode 0600 preserved), which is whatauth-agents,connect --prodand the deployment doctor checks already read
- Flags —
omnislash auth-agents [agent]— interactive CLI-agent login on the production server over SSH- Flags —
--all(every known CLI agent),--force(re-auth even if the OAuth session is alive) - Mechanism — reads
PROD_SERVER_HOST/PROD_PROJECT_DIR, checks remote credential files directly over SSH (no container spawned), thenssh -t … docker compose exec pipelines <login>—execinto the already-running owner of the credential mount, never a oneoff (a second refresh-token writer invalidates the OAuth family)
- Flags —
- Host Port Auto-Resolution —
resolvePorts()bumps a desired port on conflict and skips OS-excluded ranges, so two brains can run side by side without hand-editing anything - BYOI Detection —
detectExternalInfra(loadedEnv)recognizes an external Postgres/Redis from env and drops the local containers (and their exporters/backup sidecar) from the generated compose
Distribution & Deployment
- Unified Docker Image — one
Dockerfileat the engine root produces one base image that runs every service via entrypoint command routing (the Airflow pattern)- Commands —
bot | worker | scheduler | monitoring | mcp | webhooks | pipelines | dashboard | sync | brain-sync | db-migrate, withexec "$@"as the fallback for arbitrary commands - PID 1 —
dumb-initfor signal forwarding and zombie reaping; each serviceexec nodedirectly, no intermediate package-manager process - Pre-start hooks — git author→committer mirroring for every container, and
setup_mcp_configs(writing the omnislash MCP entry into~/.codex/config.toml) forbot/worker, non-fatal on failure - What the image carries — all engine apps and packages, pruned prod
node_modules, all three CLI agent harnesses pulled at@latestin their own layer, per-harness bubblewrap shims, Docker CLI, theomnislash/omniwrappers, plusripgrep,fd,jq,yq,tree,rsync,python3+uvfor skill scripts
- Commands —
- Base Image Extension — a brain image is
FROM ghcr.io/omnislash-ai/engine:X.Y.Zplus the brain’s own R-set and dependencies- Generated Dockerfile — rewritten on every
build/startfrom CLI templates pinned to the installed engine version;Dockerfile.overridetakes over completely when present - Workspace integration — the brain joins the engine pnpm workspace at
/app/brain, so@omnislash-ai/*resolves to/app/packages/*— one physical copy by construction, no registry round-trip and no build secret - Skill binary bake —
install[]specs in eachSKILL.mdbecomeapt-get/uv tool install/npm install -glines run as root, followed by anode-user verify step that fails the build if a baked binary isn’t onPATH .dockerignoreas the contract — the R-set is not enumerated; a singleCOPY . ./brain/ships everything except the resolved G-set plus ephemerals, so new brain-root paths need no engine change
- Generated Dockerfile — rewritten on every
- Two Delivery Channels — the Docker image delivers what to run, the npm packages deliver how to run it
- npm (GitHub Packages, restricted) —
@omnislash-ai/{cli, config, types, events, infra, integration-kit, pipeline-sdk, utils, fixtures}; the CLI tarball also carries compose templates, monitoring configs, brain-facing docs and the engine skills corpus - One registry constant —
NPM_REGISTRYis the single engine-tier statement of where the engine is published; the scaffolded.npmrcscope line, the doctor contract check, the version probe, the generated workflow auth blocks and the auth handed to a spawnedpnpm installall derive from it, so flipping it to public npm renders every one of them empty with no flags anywhere create-omnislash— a bootstrapper that owns no templates: it resolves the@omnislash-ai/cliits own install placed on disk and spawnsomnislash initwith the arguments verbatim. It is the one workspace package targeting public npm (that is wherepnpm createresolves a bare name), so the release job — which holds credentials for GitHub Packages only — excludes it and publishing it stays an owner action. Until that happens the working entry point ispnpm dlx @omnislash-ai/cli init- GHCR —
ghcr.io/omnislash-ai/engine:X.Y.Z+:latest, built from the same commit as the npm publish - Version lock — every package in the changesets
fixedgroup bumps to one number and the image tag equals it; one tag is one atomic release - Subpath-map gate —
exports(source, for the JIT monorepo) andpublishConfig.exports(dist, what a brain actually sees) are held in step bypnpm check:publish-exports, with aNARROWEDmap for packages that deliberately publish less than they export
- npm (GitHub Packages, restricted) —
- Agent Migrations — a brain upgrade is an agent’s task: every release change a brain must act on ships an upgrade entry, a markdown prompt carried in the CLI tarball (
dist/upgrades/+ manifest); protocol: upgrading.md- Entry — frontmatter
summary,phase(before-deploy/in-repo/after-deploy),severity(blocker/required/cleanup),sql(engine migrations covered, each by exactly one entry); sections Detect → Apply → Verify, optional Tell the owner; immutable once released - The task — committed
upgrade/at the brain root:TASK.md(protocol + index by phase) and each entry of (installed, target] at<version>/<NN>-<id>.md; no applied ledger — ticked per entry, deleted in the final commit - Resume — re-running
omnislash upgradeprints the unfinished task,--fromre-plans a range into it (ticks kept), doctorupgrade-in-progressnames the next entry
- Entry — frontmatter
- Compose Generation — the CLI generates every compose file from bundled templates; there is no static
docker-compose.ymlanywhere- Three template dirs —
infra/(postgres, redis, prometheus, restate, playwright-mcp, browser, n8n, nocodb, node-exporter, cadvisor),start/(engine apps in Docker +db-migrate),prod/(~30 services incl. Caddy, Loki, Alloy, Grafana, exporters, backup sidecar, brain-sync) SERVICE_REGISTRY— the single source for which services exist, their dev entrypoints, health ports, dev sidecars and whether they consume brain state; disabling one removes its template, itsdepends_onreferences, its scrape job and its Caddy vhost- Generated alongside the compose —
caddy/Caddyfile,monitoring/{prometheus.yml,blackbox,grafana/provisioning,loki,alloy},scripts/init helpers,n8n/configs, anddeploy.sh - Brain-state subpath mounts —
injectBrainStateMounts()adds onebrain_statesubpath mount per G-path to everyconsumesBrainStateservice; prod templates deliberately hardcode none, and a test asserts it stays that way - Fleet-internal addresses —
FLEET_INTERNAL_URLS(service-registry.ts) declares each in-fleet endpoint once andinjectFleetInternalUrlswritesMCP_INTERNAL_URL,WEBHOOKS_INTERNAL_URL,RESTATE_INGRESS_URL,RESTATE_ADMIN_URLandRESTATE_DEPLOYMENT_URLinto every engine-image service, skipping any address whose service this compose filters out
- Three template dirs —
- Compose Override — a brain drops
docker/compose.{prod,start}.ymland the CLI deep-merges it over the engine templates into one output file- Per-service mode — deep merge by default (objects merged, scalars/arrays replaced), or
x-override: replaceto take over a service definition entirely; the key is stripped from the output - New services — a service absent from the engine templates is added as-is
- Shipped examples —
omnislash upgradewritesdocker/compose.{prod,start}.yml.example(version-stamped, refreshed each upgrade, never loaded as an override) - Error handling — empty file silently skipped; invalid YAML or a non-object document fails with the file path and the expected structure
- Per-service mode — deep merge by default (objects merged, scalars/arrays replaced), or
- Production Env Validation — required env is derived from the templates themselves, so adding a
${VAR}auto-extends the check- Rule — any
${VAR}without a:-defaultin a prod template becomes a hard requirement;checkProdEnvRequirements()runs before generation and fails with a per-service breakdown. Two spellings are deliberately outside it:${VAR:?message}is required by compose itself on the deploy host atuptime (used where only the host can know the value, e.g.DOCKER_GID), and a${VAR}written inside a template COMMENT is prose, not configuration - Explicit additions — infra credentials (
POSTGRES_PASSWORD,REDIS_PASSWORD) when local Postgres/Redis are used; the Grafana alerting trio (TELEGRAM_BOT_TOKEN,TELEGRAM_STATUS_CHAT_ID,WEBHOOK_SECRET) when monitoring is on, because those are rendered into provisioning bydeploy.shand are invisible to the template scan; andADMIN_USER+ADMIN_PASS_HASHin domain mode with the dashboard and caddy enabled, because the dashboard’s web terminal is root on the host and those are the credentials in front of it
- Rule — any
- Domain Mode with Auto-TLS — setting
PUBLIC_DOMAIN+ACME_EMAILturns Caddy into a Let’s Encrypt terminator with per-service subdomain routing- Subdomains —
bot.,app.,grafana.,bullboard.,nocodb.,n8n.,restate.,webhooks.,artifacts.,mcp.; certs persist in thecaddy_datavolume - Only what this deployment runs — the emitted Caddyfile is narrowed against the compose service list, so a disabled service gets no vhost at all. A vhost for a container that does not exist is not inert: auto-HTTPS would obtain and renew a real certificate for a public name that answers 502
- Admin basic auth —
ADMIN_USER+ADMIN_PASS_HASH(bcrypt) gate the admin vhosts at the proxy, and the same pair guardsvisibility: 'private'published artifacts - Derived origins —
WEBHOOKS_PUBLIC_URLand the artifacts public URL are auto-derived fromPUBLIC_DOMAIN, so OAuth installs work without a second variable - IP mode — a self-signed cert on
:8443for servers with no domain;compose --prodfail-fasts oncaddy: falsetogether withTELEGRAM_WEBHOOK_URL - Extra-vhost seam — a generic
caddy-extrabind (with its mountpoint shipped in the artifact, since runc cannot mkdir inside a read-only parent bind) lets admin-supplied vhosts be added without touching the template
- Subdomains —
- Deploy Flow — build the brain image, generate the compose bundle, rsync it, and bring the stack up on the host
- Engine-generated CI —
.github/workflows/deploy.ymlis generated by the CLI and is a thin wrapper; the real logic lives in the engine-shipped.omnislash/deploy.sh, so engine deploy fixes propagate without regenerating YAML - Protected rsync —
rsync -az --delete --force --filter='P /artifacts/*** /certs/*** /.env /.env.*'keeps host runtime state (W-set, self-signed certs, the Infisical-generated.env) from being deleted - Stop-the-world deploy —
compose --prodwrites.omnislash/deploy-plan(stop|kill|migrate|up|runlines): producers, then the worker andpipelines, stop; engine Restate invocations outsideCROSSES_A_DEPLOYare killed;brain-syncstops;db-migrateruns; oneup -d --waitstarts everything; one-shotsrun --rmlast - Migration lock wait is brain-tunable —
db-migrateruns with the engine stopped, but not every client (a backup, an admin session, a service of the brain’s own), so each migration waits at mostmigrations.lockTimeoutMs(engine default 5 s) for a table one of them holds, and retries up tomigrations.lockAttempts(default 10) before the deploy stops with nothing applied; an unbounded wait would stall every query on that table behind the queued ALTER - Config-change application — every config a redeploy must change is a directory bind (single-file binds pin to an inode and silently serve stale content); daemons with a reload endpoint are reloaded, boot-only daemons get their config’s sha256 stamped into the service definition as
OMNISLASH_CONFIG_SHA256so the deploy’sup -d --waitrecreates exactly the changed ones on any compose version - Alert silencing — Grafana alerts are silenced for the deploy window so the deploy’s own stop doesn’t page
- Restarts are latency, not 502s — every
reverse_proxyin the shipped Caddyfile imports(upstream_restart)(lb_try_duration 30s): while a service’s container is being replaced its name does not resolve, and Caddy holds the request and re-dials instead of answering 502. A failed dial never reached the upstream, so any method is safe to retry
- Engine-generated CI —
- Secrets Management — the engine contracts only on a
.envfile existing on the host atup -dtime- Default flow — the deploy job SSHes in and runs
infisical exportinto$DEPLOY_DIR/.envusing a Universal Auth machine identity stored on the server; CI never holds the secrets - Alternatives — Vault / SSM / Doppler / a hand-edited file all work unchanged; local dev just writes
.envby hand
- Default flow — the deploy job SSHes in and runs
- Deployment Tuning Knobs — topology lives in
.env/ compose, never inomnislash.config.ts- A stated minimum server — 4 vCPU / 8 GB RAM / 80 GB disk; the production limits are sized for it (every memory ceiling at most half its RAM, every engine service at least a whole core, ceilings rather than reservations) and a table says what to set below it and what to raise above it (operations.md § Server size)
- Server-size knobs, one table —
<SERVICE>_MEMORY/<SERVICE>_CPUSfor every app, data and sidecar service (bot, worker, pipelines, dashboard, mcp, webhooks, scheduler, monitoring, brain-sync, caddy, postgres, redis, restate, playwright, browser, n8n, nocodb), plusWORKER_REPLICAS(3),AGENT_EXECUTION_CONCURRENCY(12, a cluster-wide Restate semaphore),PIPELINES_PIDS/PLAYWRIGHT_PIDS/BROWSER_PIDS/WEBHOOKS_PIDS,REDIS_MAXMEMORYandRESTATE_ROCKSDB_MEMORY, each with its default in operations.md § Server size. A retired spelling (PIPELINES_MEMORY_LIMIT,WEBHOOKS_MEMORY_LIMIT) failscompose --prodnaming its replacement, rather than silently running the default Host resourcesdoctor check — the host the deployment runs on against the minimum server, and every container’s real ceilings (cAdvisor, through Prometheus) against the host: a warning below the minimum, and on a memory ceiling that is missing or at or above the host’s RAM, which can never bite; the pass names what the ceilings add up to (doctor_check, the dashboard’s Doctor page)BACKUP_SCHEDULE/BACKUP_KEEP_{DAYS,WEEKS,MONTHS}—pg_dumpsidecar cadence and rotation
- Production Hardening — every prod app service ships with healthchecks,
deploy.resourceslimits,security_opt: no-new-privileges,read_onlywhere possible,tmpfs, json-file log rotation,stop_grace_periodandoom_score_adj, on three isolated networks (frontend,backend,monitoring) - Volume Architecture — one named volume per durability class
brain_state— the G-set, mounted whole bybrain-sync(where.gitlives, its only writer) and read-only by the dashboard (the “Start a loop” chooser reads the brain’s git history), and exposed to every other consumer as per-path subpath viewsbrain_baseline— brain-sync’s full clone, the git-alternates source for per-run sandbox checkouts; workers mount it read-onlyworker_runs— per-run sandbox checkouts, surviving restarts so a HITL pause→resume works./artifactshost bind — the W-set at$ARTIFACTS_ROOT, a sibling of/app/brainso a per-run sandbox bind can never shadow itpostgres_backups,caddy_data,alloy_data— dumps, ACME certs, log tail positions
- CLI Agent Auth on the Server — interactive browser login is the one step CI cannot automate
- Credential mounts —
~/.claude,~/.codex(rw) and~/.ssh(ro) are bind-mounted into the container, so credentials survive container recreation;CLAUDE_CONFIG_DIRpoints into the mount so a recreate doesn’t blank the OAuth config - Single writer rule — only the running
pipelinescontainer may hold the rw credential mount; a second container sharing it becomes a rogue refresh-token writer and invalidates the OAuth family.auth-agentsand the dashboard web terminal bothexecinto that one container - Doctor coverage — credential file presence, token validity/expiry,
PROD_SERVER_HOST, SSH connectivity and deployed image versions
- Credential mounts —
- CLI Agent Sign-In from Chat —
/connect→ 🤖 Agent sign-in re-authorizes a CLI harness without a shell, closing the one credential a chat-only owner could see break but not repair- Headless by measurement, not by wrapper — both logins run with stdio fully piped and no TTY:
claude auth loginprints its URL as plain text and reads the pasted code off stdin,codex login --device-authprints a URL plus a one-time code and polls by itself. No PTY, no terminal emulation; the dashboard terminal stays the fallback, not the mechanism - Two harness shapes, one flow —
CliLoginSpec.codeEntrydiscriminates them:stdin(Claude — the link rides the question card, the reply goes to the login’s stdin, a rejected code is re-asked, 3 asks in all) andnone(Codex — link plus code posted, nothing comes back through chat) cli-authVirtual Object, keyed per harness — runs inpipelinesbecause that container alone mounts~/.claude/~/.codex, and its per-key lock IS the single-writer guarantee against two concurrent logins invalidating a single-use refresh-token family (something the web terminal, where two tabs can both run the login, never had)- The subprocess outlives a suspension — spawned inside
ctx.run, parked in a process-local registry keyed by invocation id, bridged back through an awakeable (the shapeagent-vouses for an agent run). Each login’s OAuth challenge is minted per spawn, so the process that printed the link is the only one that can finish it - Verdict is read, not assumed — the exit code alone proves nothing, so the credential file is re-parsed through the same parser the status snapshot uses, and the snapshot is republished immediately rather than at its 15-minute cadence
- Owner-only, by the callback classifier —
/connectitself is a member command by default (a member links their own Telegram account through it), but every button outside an addressed HITL question is owner-only through one prefix classifier ahead of every handler, so a member’s tap on the sign-in menu is refused privately. The menu alone discloses the deployment’s credential status and plan tier - A duplicate tap is not a second login — a request arriving within a minute of a successful sign-in stops there, instead of minting a fresh OAuth challenge and rotating a single-use refresh family for nothing. Scoped to that window and keyed on the last SUCCESS, deliberately: a credential whose refresh the provider revoked still reads as a live session off the file, so a health check would refuse the sign-in to the owner who most needs it — and chat has no
--forceto overrule it with - Bounded — 45s for the link to appear, 10 minutes for the human (inside Codex’s own 15-minute device-code lifetime, so the deadline that fires first is the engine’s); an abandoned or restart-orphaned login reports as expired and its subprocess is killed
- Headless by measurement, not by wrapper — both logins run with stdio fully piped and no TTY:
- Prod SSH Seam —
execInProdService(packages/cli/src/prod-ssh.ts) is the one code path for running a CLI command inside a production container overssh -t+docker compose exec; shared byauth-agentsandconnect --prod - Post-deploy image cleanup —
scripts/docker-cleanup.shruns at the tail of every deploy (best-effort, after the deploy reports healthy): keeps the N most recent tags per repository, prunes dangling images and, on hosts that build, the buildx cache. It replaced a residentmaintenancecontainer that ran the same script on a daily sleep loop as root with a read-write docker socket — a permanent host-root process to reclaim disk on a pull-only host that only gains images during a deploy - Image & Action Pinning — every third-party image in
templates/prod/andtemplates/infra/is pinned to a concrete tag and every third-partyuses:in the generateddeploy.ymlto a full version;image-pins.test.tsfails CI on a:latest/tagless/bare-major image (redis:8-alpinemoved to a new patch under a deploy and restarted Redis under every app) or a floating-major action, and requiresredisandpostgres— the state every app sits on — to be pinned by digest too, since official images are rebuilt under an unchanged tag, and the same gate pins the Restate test server to the version production runs
Data & Persistence
- PostgreSQL + Drizzle ORM — one database, one Drizzle client, two PostgreSQL schemas
- Multi-schema layout — engine tables live in
omnislash(pgSchema), brain tables inpublic(barepgTable()imported from@omnislash-ai/infra/drizzle), composed via TypeScript imports with zero codegen - Engine has no database — it ships schema definitions and its migration line as code inside the npm package (
files: ["dist","migrations"]); the brain owns the instance - Type derivation — the Drizzle table is the only source of types;
Row/Insert/CreateParams/UpdateParamsderive inschema/types.tsviaOmit/Pick/Partial, and every JSONB column carries.$type<T>() - Managed or self-hosted — the same code targets a compose-managed Postgres 17.9 (tuned
shared_buffers,pg_stat_statements) or any managed provider (RDS, Neon, Supabase, Cloud SQL) viaDATABASE_URL
- Multi-schema layout — engine tables live in
- Two Ordered Migration Lines — schema reaches a database only through committed, ordered SQL
- Engine line —
packages/infra/migrations/, PG schemaomnislash, journaldrizzle.__engine_migrations, applied first and unconditionally so a brain table may FK intoomnislash.*but never the reverse - Brain line —
{brainRoot}/db/migrations/against{brainRoot}/db/schema.ts, PG schemapublic, journaldrizzle.__brain_migrations; the schema path is a convention probe, not a config key (BRAIN_SCHEMA_PATHis gone) - Neutral journal schema — both journals live in a third
drizzleschema so the drift oracle’s per-lineschemaFilternever proposes dropping them - Brain snapshot isolation — the brain snapshot is generated from the brain module alone;
schemaFilterscopes tables but not enums, so re-exporting an engine table would leak every engine enum into the brain line - One entry point —
packages/infra/src/db/cli.tsserves the container (db-migrate), the monorepo (pnpm -C packages/infra db:*) and a brain (omnislash db …via the./db-clisubpath export)
- Engine line —
- Migration Runner Guarantees — a hand-written runner, not drizzle-orm’s
migrate()- Per-migration transaction with the journal row inside it — a failure rolls back the migration and its record, so a retry resumes exactly where it stopped; never “applied but unrecorded”
- Transaction-scoped advisory lock —
pg_advisory_xact_lock(4027513298)taken before the journal is re-read, so two racing deploys serialize and the loser skips rather than replaying DDL; transaction-scoped specifically becauseDATABASE_URLmay point at a transaction-mode pooler where a session lock would be silently void - Settings that survive pooling —
statement_timeout = 0,idle_in_transaction_session_timeout = 0andsearch_path = publicre-applied withSET LOCALinside every migration transaction - Checksum immutability — every journal row must still match its file byte-for-byte (sha256); editing an applied migration fails the run
- DB-ahead-of-build refusal — a journal row with no file on disk stops the run, making a rollback loud instead of silently re-applying old DDL
- Out-of-order refusal — a pending migration below the applied high-water mark is rejected
- Failure is fatal — a non-zero exit fails the
db-migrateone-shot’sservice_completed_successfullygate and stops the deploy, instead of crashlooping workers against a half-applied schema
-- omnislash:no-transactionMigrations — an opt-out for statements Postgres forbids inside a transaction block- Enforcement at read time — a file containing
CREATE/DROP INDEX CONCURRENTLYwithout the marker throws before anything is applied, andgenerateadds the marker automatically when the diff contains one - Invalid-index cleanup — a failed
CREATE INDEX CONCURRENTLYleaves an INVALID index holding the name; the runner drops it before rethrowing, so the retry isn’t wedged forever
- Enforcement at read time — a file containing
- Migration Authoring Flow — generate, read, apply, commit the SQL and the snapshot
generatenever touches a database — it diffs the TypeScript schema against the line’s head snapshot and writesNNNN_name.sql+meta/NNNN_snapshot.json+ the journal entry- Destructive-statement warning —
DROP COLUMN/DROP TABLE/DROP SCHEMA/DROP TYPEare re-printed under a loud “this migration DESTROYS data” banner - Renames are hand-edited — drizzle-kit’s interactive resolvers are patched out (a prompt in a non-TTY deploy container used to hang), so a rename is emitted as drop+add and must be rewritten to
RENAME COLUMN; safe because the snapshot derives from the TypeScript schema, not the SQL - Hand-authored non-schema migrations —
CREATE EXTENSION, backfills and grants are written by hand with the previous snapshot copied forward; a content-equal snapshot is what marks them as outside the schema proof so adoption executes rather than stamps them - Completeness gate — a unit test asserts
diffAgainstHead()is empty for both lines, catching “editedschema.ts, forgot to generate”
- Legacy Adoption — a database built by
db push(declared tables present, empty journal) is proved equal to the build’s schema and stamped rather than replayed- Read-only drift oracle —
inspectDrift()usesdrizzle-kit push’s diff engine as an oracle andclassifyDrift()sorts its output intoblocking(missing objects — stops the deploy),extra(undeclared objects, e.g. tables an agent created withdb_execute— logged, left alone) andtolerated(two provable drizzle-kit introspection artifacts: array-column defaults and expression-column indexes, both derived from the schema, never hardcoded) - Trigger asks about declared tables — not “is the namespace non-empty”, because
publicis shared and a brain’s very first migration would otherwise be sent through a proof it cannot pass - Release rule — the engine release that introduces migrations must carry no schema change of its own, or every live brain’s adoption proof fails at once
- Escape hatch —
db baseline --forcestamps everything without introspecting, which is also the answer when the drizzle-kit patch the oracle needs isn’t present in a brain’s ownnode_modules
- Read-only drift oracle —
- Engine Schema Surface — 43 tables in the
omnislashschema behind 70 shipped engine migrations- Domains covered — agents + conversations + conversation events + chat state + agent topics + chat log & watermarks, executions, workflow runs/task runs/cycles, evaluations, pipeline invocations/steps/events/journal events, grants, HITL interactions, connections + OAuth clients, MCP catalog entries, published artifacts, external (ingest) events, MTProto sessions, media transcripts, skill settings, business connections + business chat state, the KV store and the content-addressed system-prompt store
- Snapshot invariant, per-reader policy — an unresolvable
workflow_task_runs.task_slugthrows for readers that score or repair a run (eval, loop, recovery) and is dropped-with-log.errorfor the run’s own execution path, because a throw inside a durable Restate step would wedge the handler forever
- Executions — one record per run —
omnislash.executionsholds one row per workflow run, pipeline invocation, heartbeat tick, hook fire (skipped and gate-failed ones included), eval and cycle step; the kind tables stay the detail, and a chat turn is not a run (it belongs to a session)- What a row says —
kind, the entity it belongs to (entity_kind/entity_id, both null for ad-hoc or system work), who started it (trigger_actor; the trigger word —cron · event · manual · agent · parent · chat— is derived from it at read bytriggerOf, never stored), the execution it ran inside (parent_execution_id) or re-runs (rerun_of), its detail row (detail_id),expected_atfor a scheduled start,started_at/ended_at,status+status_reason,error_class, andcost_usd - One run vocabulary —
Queued · Running · Needs you · Paused · Retrying · Succeeded · Failed · Cancelled · Skipped, with a reason on exactly two of them (Needs you:answer/approve; Paused:by_you/gave_up). Every engine state maps through one function,toRunStatus(@omnislash-ai/types/run-status):pending→ Queued,awaiting_input/awaiting_approval→ Needs you,awaiting_retry→ Retrying,completed→ Succeeded, an interrupted turn → Cancelled, a hook whose gate said no or that hit its rate limit → Skipped, a gate that threw on its last attempt → Failed. Tool failures inside a run that completed are a warning, never a status - Empty until its writers land — the table and its write API ship first; each kind’s durable owner starts writing its rows in a later release. The write contract they follow: at start (
upsertExecutionStart, idempotent on the id, so a retried producer lands on its first row) and on every status change (projectExecutionStatus), both inside the owner’s own transaction; the projection is terminal-CAS — a finished row never moves again — and creates the row when the start predates it. The execution id is the detail id for workflow runs, pipeline invocations and evals, and a derivedexe_…for ticks, fires and cycle steps - Heartbeat ticks and hook fires — every tick and every fire is a run, written under one id per BullMQ job (
deriveId('exe', <job id>)), so a retried job is one row. A tick or a fire that ends before an agent exists is still a run: a tick the scheduler brake dropped, or of a disabled heartbeat → Skipped; of a heartbeat whose file does not parse → Failed (definition_unparsable); a tick or a fire whose heartbeat or hook definition is gone → Failed (definition_missing); a hook whose gate said no or that hit its per-chat rate limit → Skipped; a gate that threw → Retrying while the job has attempts left, Failed (gate_error) on the last one, so a gate that throws once and then passes ends Succeeded on the same row. Once the agent is dispatched (detail_idnames it), the agent’s own durable owner moves the row with the turn — Running, Needs you while it waits on a person, then its terminal — so the run ends even when the job that dispatched it is gone (a deploy mid-tick). Ascope: chathook’s fire succeeds once its message is in the chat agent’s conversation; a scenario-inline hook’s fire is a run inside the run it is scoped to - Runs inside runs — every agent turn carries the execution it runs inside (
context.auth.execution:{ id, entity }— a workflow task, a pipelinerunAgent, a tick, a fire; inherited by every sub-agent), and a workflow or pipeline an agent starts records that execution as its parent: a workflow a heartbeat’s agent starts is the tick’s child - Evals and cycle steps — every evaluation is a run (id = the eval id) whose entity is the graded target and whose parent is the graded run (a heartbeat grade hangs under its tick; a chat episode’s grade has none): Queued when booked, Running while graded, then Succeeded or Failed — a grade re-done after a failure is the same run, reopened, and a booking undone before its job was queued disappears with it. A self-evolving cycle is one run per step it runs —
analyze,apply,check,watch,revert(CYCLE_STEPS,@omnislash-ai/types/loop; the step is read off the id withcycleStepOf) — each with the cycle’s target as its entity and the grade that opened the cycle as its parent; the wait for your decision between steps is the cycle’s request, not a step. A refined analysis or a retried apply reopens its step’s run, so a cycle never has more than five. A step ends Succeeded when it did its job, Failed when it broke (a check that could not measure, a revert PR left unmerged) or when the cycle was released with it still running (the revert’s, when the engine gave up on the cycle), Cancelled when the cycle was set aside under it (or a revert PR was closed), Skipped for a watch underloop.watch.mode: none. The pre-merge check’s replays are runs inside itscheckstep, and a replay of a history input records the run it re-runs (rerun_of). Evals and cycle steps are runs ABOUT an entity, never the entity’s own last run
- What a row says —
- Prefixed Ids — one closed map of engine id prefixes, one per engine-minted id (
@omnislash-ai/types/ids:run_,pinv_,exe_,ses_,req_,cyc_,eval_, …):newId(kind)(@omnislash-ai/types/id-mint) mints prefix + cuid2,deriveId(kind, seed)(same module) the same id for the same seed (a retry-stable id),stripIdPrefixdrops it for a byte-budgeted wire. The prefixes apply to newly minted ids — tables move onto them in a later release, and natural-key tables (agent_topics,bot_chats,chat_state, …) have none. A prefix is a display hint, never a validity check — ids minted before the prefixes stay valid beside them - Content-Addressed System-Prompt Store —
agent_system_promptsis keyed by the sha256 of the prompt body, so tens of thousands of turns reference dozens of rows; bodies are never swept because a collector would cost more than it reclaims - KV Store —
key_value_storeis a scoped, JSONB-valued, optionally-expiring key/value table (unique(scope, key), indexed by scope) — the persistent cross-step state pipelines and brain code use - Blob Storage —
BlobStorageRouterroutes by URI scheme with a pluggable provider set and a local default- Content-addressed local provider —
persist()copies the source into$ARTIFACTS_ROOT/blobs/, staging through a reaper-sweptblobs/.tmp/so a crash between copy and rename leaks into something that gets cleaned; the copy is the snapshot, so a ref survives its source being rewritten or reaped - Symlink and containment guards — the source is opened
O_NOFOLLOW, andretrieve()refuses any path outside the store root, closing the “a blob ref can point anywhere on disk” class of bug in one place - Size cap with honest fallback —
BlobTooLargeErroris a verdict on the file, not a provider failure, so it is never retried against the local fallback; any other external-provider failure does fall back to local - Ref shape —
local://<abs path>, deliberately a real path soprocessApiStreamand CLI agents can read it natively; content addressing lives in the directory layout
- Content-addressed local provider —
- Artifacts Root —
$ARTIFACTS_ROOT(/var/lib/omnislash/artifacts, a host bind, sibling of/app/brain) holds run artifacts, agent file-sends, conversation uploads and scratch caches- Retention policy —
artifactsRetention.{ttlHours, maxSizeMb}, two independent optional dimensions over the caller-owned subtrees (agents/,telegram/), oldest-first eviction; with both unset the reaper deletes nothing and a doctor check warns past 5 GB (or when the tree is too big to count in 20 s). Deleting an agent removes itsagents/<id>/directory; an agent’spnpm installkeeps its store in the container’s~/.cache/pnpm, never on the artifacts volume - Agent-row retention —
agentRetention.ttlHourssoft-deletes then hard-deletes agent rows via the pipelines service’s sweep (disablingservices.pipelinestherefore disables it); the hard delete cascades into the agent’s conversations and events, which is also what releases the blobs those events pinned - Journal retention — an opportunistic retention sweep trims
pipeline_journal_events
- Retention policy —
- Redis as State Substrate — beyond queues, Redis carries the engine’s coordination and ephemeral state
- What lives there — agent output streams, event pub/sub, planning and eval snapshots, queue-depth stats, the service registry heartbeats, rate-limiter and circuit-breaker counters, per-connection refresh locks, the reducer leader lease, and callback/state keys (ingest dedup is not here — it is a Postgres unique index, see the webhooks service)
- Connection factory —
createRedis/createConnectionFromEnvwith outage-aware reconnect logging;resolveRedisUrl()is the one place a caller resolves the connection it actually uses (parts or URL)
- Database Connection Handling — one SSL/credential resolution shared by the runtime pool and drizzle-kit
- SSL knobs —
DATABASE_SSL_REJECT_UNAUTHORIZED,DATABASE_SSL_CA_PATH; local hosts and?sslmode=disableskip TLS entirely - Query budget —
DATABASE_QUERY_*timeouts at the app level, explicitly neutralized inside migration transactions so DDL is never aborted midway - Resilience helpers —
withRetrywith transient-error classification, plusisUniqueViolation/isNotFoundError/isTransientErrorpredicates on the error boundary
- SSL knobs —
- Backups — a
postgres-backupsidecar (pg_dumponBACKUP_SCHEDULE, day/week/month rotation into thepostgres_backupsvolume) is auto-included whenever the compose runs its own Postgres and excluded under BYOI- Monitored — an HTTP health endpoint scraped through blackbox-exporter; two hours of failed probes fires the
postgres-backup-missingalert to Telegram - Streamed restore — dump is piped container-to-container (
postgres-backup cat | gunzip | postgres psql), no host paths and no root
- Monitored — an HTTP health endpoint scraped through blackbox-exporter; two hours of failed probes fires the
- Drizzle Studio —
omnislash db studioopens a schema browser over the resolved connection (both PG schemas visible) - NocoDB Database Browser — an optional Airtable-like data browser (
services.nocodb), auto-provisioned by an init container that creates its own metadata database and data sources, behind Caddy basic auth in production - Local → Prod DB Access — every
omnislash dbsubcommand takesDATABASE_URLfrom the environment (falling back to compose-derived credentials), so a one-off shell reaches a managed DB directly or a self-hosted one over an SSH tunnel; there is no--prodflag, anddb push/omnislash startagainst a prod URL are explicitly forbidden
Infrastructure
- BullMQ Queue System — Redis-backed queues are the engine’s at-least-once work substrate
- Named queues —
workflow-execution,ui-notifications,workflow-evaluation,pipeline-evaluation,heartbeat-evaluation,planning,memory-ops,heartbeat,hook-trigger,pipeline-trigger,hitl-reconcile,repo-sync,dispatch-delivery,root-agent-dispatch,schedule-prompt - Deterministic job ids — the producer consumes the event log as group
dispatch(an event emitted while the workers were down is delivered once they are back) and keys eachjobIdby the event’s log entry id, so a redelivery collapses onto one job, giving free retry + DLQ - Shared job options — canonical job names per queue,
resolveAttempts/resolvePriority/standardDefaultJobOptions,DEFAULT_JOB_HISTORY_LIMIT, and per-domain retry chains (e.g. eval jobs back off 5→10→20→40 min so a retry outlives the 30-minute running-row lease) - Client contract —
QueueClient/JobHandleinterfaces with a BullMQ implementation, so producers never import BullMQ directly
- Named queues —
- Worker Service — the BullMQ job processor, scalable to
WORKER_REPLICAS- Workers — planning, workflow trigger, eval, heartbeat, hook trigger, pipeline trigger, HITL reconcile, memory ops, dispatch delivery, system-agent dispatch (with per-kind handlers), schedule-prompt, repo-sync, boss-expire
- Graceful shutdown — signal registration, in-flight drain, and an
AbortSignalthreaded into long git/agent operations
- Event System — a typed Redis event layer with two complementary transports
- EventBus — every event appended to a capped log and published:
observe/observeOnce/observePattern('prefix:*')/observeAllread it live (pub/sub, lossy),consume(group)durably (once per group, caught up after a restart), with Zod validation at both ends and anonEventErrorchannel instead of throws - StreamBus (Redis Streams) — persistent, at-least-once, consumer groups,
appendAs/consumeAs - Exactly-once discipline — every consumer is classified observational / work-triggering / delivery, with three sanctioned patterns for work-triggering ones: a journaled
ctx.runstep in the entity’s durable owner, aconsumegroup handing off to a keyed BullMQ job, or a CAS/idempotent effect at the side-effect itself - Delivery restart-loss → boot scan — an outbound card dropped in the emit→send window is recovered by scanning the owning table on boot and re-driving un-delivered rows, never by re-emitting
- Single-owner snapshot reducers —
RedisLeaderLeaseonreducer:leaderelects the one worker replica that reduceseval:*/planning:*into a snapshot; the lease releases immediately on clean shutdown, andEvalChannelheals the crash gap by reconciling still-runningsnapshots against the durable row on acquire - Reliability — DLQ per stream with metadata, poison-pill quarantine before deserialization, XAUTOCLAIM recovery of a dead consumer’s pending messages, exponential consumer backoff to 30 s, and a shutdown that waits for the current batch
- Typed surface —
EventPayload/StreamPayload/WildcardPattern/WildcardMatchhelpers driven by the schema union, plus per-event.meta({ category: 'system' | 'user' })that drives activity-feed filtering
- EventBus — every event appended to a capped log and published:
- Webhooks Service — a Hono HTTP→queue bridge on
WEBHOOK_PORT(3200)- Tool execution —
POST /tool/:nameruns any registered tool withwebhook !== falseunder a headless context (surface: null, subjectId: null), so only non-HITL tools are reachable; the registry behind the door carries the full integration surface plus brain tools and its ownevent_ingest(fresh events under any source name) — no engine op: an external system starts a scenario by posting an event to/event/:source(a source the brain declares) or/tool/event_ingestand a hook - Event ingest —
POST /event/:sourcelooks up a braindefineEventSource, calls itsextract()(returningnull→ 401), then records it throughevent.ingest’s own function (ingestEvent,@omnislash-ai/engine-api/event-ingest) and dedups by persisting: one upsert intoomnislash.external_eventsdecides insert / revive / duplicate; a202means recorded, and the event’s hooks and dispatch run asynchronously from its route job (below); there is no provider-specific code in the engine - Durable inbound outbox —
omnislash.external_eventsis the one inbox for every inbound event (webhook ingest,event_ingest, the Telegram user-account listener): a recorded row wakes one keyedinbound-routejob in the worker, the ONLY thing that triggers work for it — it fires the matching hooks (one fire per event, keyed by the row id, so retries and re-routes never fire twice), runs the outbounddispatchtargets on it, marks the rowprocessedand only then publishes the event on the bus for observers. A row whose job never started is re-routed by a sweep within about two minutes, so an accepted event cannot be lost to a Redis blip or a crash; a row its consumers keep failing endsfailedwith the reason, and a re-delivery of its key routes it again. Rows carryresources(correlation keys such asgithub:pr:<owner>/<repo>#<n>) - Body size cap — every anonymous door (
/event/:source,/github/webhook, integration routes) answers413before buffering a body over its cap: 25 MiB by default, a source’s ownmaxBodyBytes, GitHub’s 25 MB for the App receiver - Reserved source names — the engine’s own sources (
mtproto) cannot be declared or ingested by a brain, so nobody can mint a typed engine event through the door - Per-source credentials — a source’s optional
verify(ctx)replaces the sharedWEBHOOK_SECRETfor that door alone (itself checked the same timing-safe way);bearerToken(...secrets)is the shipped implementation, comparing SHA-256 digests viatimingSafeEqualand accepting several independently revocable secrets, and running before the unknown-source answer so anonymous callers cannot enumerate sources - Permanent deduplication in Postgres — a partial unique index on
(source, idempotency_key) WHERE idempotency_key IS NOT NULL, claimed by the singleINSERT … ON CONFLICT … RETURNINGthat ingests the event, so there is no read-then-write window and no expiry: a key is taken forever. A key whose routing endedfailedis revived by the next delivery (same row, fresh payload) — without that, a failed routing would turn the sender’s correct retry into a permanent silent duplicate. No row returned = duplicate =200 - Integration routes —
<METHOD> /webhooks/:integration/:pathmounted fromdefineIntegration({ webhooks }), authorized by the handler’s ownverifySignaturerather than the bearer token - Raw body capture —
rawBodyMiddlewaresnapshots the exactBufferbefore parsing, so HMAC verification signs real bytes and not re-serialized JSON - OAuth + admin routes —
/oauth/init,/oauth/callback,/oauth/status/:hitlId,POST /api/connections/byot(all bearer-gated except the browser callback) - GitHub App registration page —
GET /github/app-register?state=…[&org=…], the link both the dashboard andgit_request_apphand out: it verifies the signedstatewithout burning it (a reload works) and answers an auto-submitting form that POSTs the manifest — built once at boot fromgit.appPermissions/git.appPublic— to GitHub’s (org or personal) registration screen,no-store,no-referrer, a CSP whoseform-actionis github.com only - GitHub App registration callback —
GET /github/app-callback: it verifies an HMAC-signedstate, burns its nonce with a RedisSET NXreplay guard, converts GitHub’s one-hour manifestcodeinto the App’s credentials, seals them intoomnislash.git_appsand continues straight to GitHub’s install screen under a fresh install state, carrying an agent card’s binding over to it. Mounted only when bothWEBHOOKS_PUBLIC_URLandDASHBOARD_PUBLIC_URLresolve - GitHub App installation callback —
GET /github/install-callback, the App manifest’ssetup_url: thestateis optional (an install can start straight from the App’s GitHub page) but is verified and nonce-burned when present, and every column of theomnislash.git_installationsrow comes from an App-JWT installation read, so a forgedinstallation_idgets a 400 and writes nothing. A flow an agent’sgit_request_appcard started answers that card with what GitHub confirmed and ends on a success page; an org member’s install request (setup_action=request) only shows a page — nothing there is GitHub’s word, so the card stays open for the real installation, Done or its expiry; any other returns to Connections › Repos (/go/query:repo.list). Same two-origin mount gate as the registration callback (the event receiver below is not gated) - GitHub App event receiver —
POST /github/webhook, the App’s own delivery door:X-Hub-Signature-256is verified as HMAC-SHA256 over the RAW body (never a re-serialization) against the sealedwebhook_secret, length-guarded beforetimingSafeEqual, and a missing header, a bad signature or no stored secret all end at401having done nothing. A verified delivery is recorded and answered202(200 duplicatefor a redelivery); an event or action outside thegithub:*family is an ignored2xx, because a non-2xx marks the delivery failed in the App’s Advanced tab and that log is the admin’s only signal the receiver works; a recorded event in a shape GitHub changed is a400. Mounted unconditionally, unlike the two browser callbacks — a server-to-server receiver needs neither public origin, and gating it on the dashboard’s would silently drop deliveries on a deploy that has no dashboard - Published-artifact origin —
/p/:id,/p/:id/rawand/a/:aliasserve published artifacts with ETag/conditional GET, CSP sandboxing,nosniff,noindex, and Basic auth for private publications - Diagnostics + lifecycle —
GET /health,/ready,/metrics; idempotentstop()that runs shutdown hooks, closes the HTTP server, deregisters from the service registry and settles every connection
- Tool execution —
- Outbound Dispatch Targets —
defineProject({ dispatch })POSTs engine events to external URLs- Subscription — event names with
*wildcard suffixes plus an optional exact-match payload filter, per-targetenabledtoggle - Delivery — custom headers merged with defaults, optional HMAC
X-Omnislash-Signature, and one keyeddispatch-deliveryjob per (target, event emission) with BullMQ retry/backoff/DLQ. At least once: a retried or redelivered POST carries the sameX-Omnislash-Delivery-Id, stable per (target, event emission) — the receiver dedupes on it - Outcome and test — a finished delivery records what it did (
delivered,rejectedby a non-retryable 4xx,blockedby the outbound URL check,skippedwhen its target is no longer configured) as its job’s result, whichendpoint_getlists beside the failed and queued ones;endpoint_sendsends a target a test delivery of its first event’s catalogue sample, markedtest: truein the body andX-Omnislash-Test: true
- Subscription — event names with
- Health & Readiness —
HealthCheckerwith uptime tracking, registered per-check gauges, and a small HTTP server exposing/health,/readyand/metricsfor every service; shipped checks cover database and Redis reachability - Service Discovery — a push-based Redis registry: each instance heartbeats
svc:<name>:<instance>:heartbeat(15 s interval, 45 s TTL) with its status, uptime and per-check results into an index set, so a crashed instance disappears by key expiry and multi-instance services are visible individually with astaleflag - Distributed Rate Limiting — one Redis limiter shared across bot / worker / mcp / pipelines
- Atomic multi-key Lua — per-minute and per-day windows checked and incremented in one script, returning which limit was exceeded;
RateLimitErroron rejection - Category vs bucket — the
categoryis the low-cardinality Prometheus label, the optionalbucketis where the counter actually lives, so per-id limits never mint a time series per id - Inspector + metrics — a read-only inspector over current counters and
rate_limiter_results_total
- Atomic multi-key Lua — per-minute and per-day windows checked and incremented in one script, returning which limit was exceeded;
- Distributed Circuit Breaker — Redis-backed breaker with atomic Lua scripts, so one process detecting a dead provider trips it for every container
- State machine — closed → open after
thresholdfailures (default 3) → half-open aftercooldownMs(default 30 s), with key auto-expiry (1 h) and anonStateChangecallback - Inspector + metrics — current per-provider state readable for the dashboard,
circuit_breaker_rejections_totalcounted
- State machine — closed → open after
- Redis Coordination Primitives —
withRedisLock+RedisLockHandle.extend()(Lua CAS re-assert of ownership before an irreversible step;falsewhenever ownership cannot be CONFIRMED, never a throw), with every command the lock issues deadline-bounded — an unreachable Redis fails the acquire withRedisLockUnavailableErrorinstead of hanging the caller forever, and a release that never answers cannot strand a critical section whose result is already in hand;RedisLeaderLease(long-lived renewing lease with immediate CAS release on clean shutdown), plus in-processSingleFlight,LRUCacheandLockService - Record & Replay — capture any live session into JSONL and replay it deterministically into a sandboxed Postgres + Redis
- Zero-overhead taps — five writers (Drizzle
loggerDML,EventBus.emit,AgentChannelwrite/state/reset,WorkflowChannelsnapshot/log/clear,QueueStatsWriter) callgetRecorder()?.write(...), which collapses to a null-check whenOMNISLASH_RECORD_DIRis unset - Manifest and clock origin — the first process atomically creates
manifest.jsonviaO_EXCL; every record’stis relative to onestartedAt, one file per (app, pid) - Replay engine — k-way merge-sort across every
.jsonl, a self-correcting clock that targets wall-time and never drifts positive, and per-fixtureDatesentinel revival so “last 1h” dashboard filters work on a week-old fixture - Multi-fixture composition — N fixtures with independent offsets along the player wallclock, from positionals, an interactive picker, or a reproducible YAML compose plan;
--resetruns once for the composed unit and--looprestarts the whole unit - Safety gates —
--resetrefuses a target matching the primaryDATABASE_URLand requires--yes; the player refuses to start withOMNISLASH_RECORD_DIRset (it would re-record its own writes) - Documented accepted loss — raw-SQL paths, rolled-back transactions, BullMQ job state, non-JSON param types, no secret scrubbing, cross-fixture PK collisions
- Zero-overhead taps — five writers (Drizzle
- Multi-Repository Operations (attached repos) — agents work across additional git repositories beside the brain
- Attachment, not declaration —
omnislash git attach <owner/repo> [--slug] [--branch]writes acode_reposrow (slug validated against^[a-z0-9][a-z0-9-]*$because it becomes a path segment, installation resolved from the repo owner,default_branchNULL = ask the remote);omnislash git reposlists the attached rows with their sync state plus what the installations can reach, andomnislash git detach <slug>drops the row. Every row rule — a safe slug, a git URL, no credential in its userinfo, and not the brain repo’s own name — is enforced insideattachCodeRepo, the single write every entry point goes through, so the CLI, the dev seed and the dashboard cannot each forget one.EXTRA_REPOSsurvives as a local-dev seed only — upsert-only at worker boot (the one service that runs in every mode), and it never removes an admin-attached repo - Resolution — the map is read live from the rows on every call (no DI snapshot), resolved to
/repos/<slug>in Docker or a sibling checkout next to the brain root in local dev, with the brain repo always first - Clone on attach —
brain-syncclones every attached row on boot, and the repo-sync sweep clones a row whose checkout is missing, so a repo attached at runtime lands in/repos/<slug>without a redeploy;~/repos:/reposis mounted into the code consumers. A private GitHubhttps://remote is served per repo by an installation token minted from the deploy’s App (header throughGIT_CONFIG_*, never in the URL, and a failed mint only degrades that repo to ambient auth);~/.ssh+~/.gitconfigstay mounted read-only for the ssh remotes the App cannot serve. A clone or fetch failure lands in the row’ssync_status/last_errorinstead of a log line nobody reads - Workflow targeting with write-back —
agent.repo: <slug>gives the run a per-run sandbox cloned from/repos/<slug>, whoseoriginis the real upstream, so an agent’sgit push origin <branch>lands on that repo’s remote - Inbound repo-sync worker — a repeatable sweep (
REPO_SYNC_INTERVAL_MS, default 5 min, plus a startup kick) that fetches, self-heals a wedged checkout (rebase --quit, hard reset to origin, stash clear), commits dirty tracked edits as a snapshot rather than stashing, thenpull --rebase, each repo under a composed shutdown/timeoutAbortSignal - Per-repo conflict episodes — a real rebase conflict opens a supervised episode (one ⚠️, a deduplicated
repo-conflict-resolvedispatch, a hold while the resolver owns the checkout, deadline restart, verify-by-clean-pull, ✅ close, 🆘 at attempt 3 and every 10), adopted after a worker restart - Clean checkouts — skills reach an agent in an attached repo through the system-prompt catalog under the absolute skills root, never by writing symlinks into the repo’s working tree
- Attachment, not declaration —
- Config & Env System — a Zod-validated env layer with a catalog, merged brain shapes and walk-up discovery
- Categories — Telegram, LLM Providers, CLI Agents, Agent Sandbox, Brain Sync, Database (PostgreSQL), Redis, Queue & Workflow, HTTP Ports, Restate, Observability, Integrations, MCP Context, Docker & Project, Production Access, Fixtures
getEnvCatalog()— the merged engine + brain + derived shape, rendered as the dashboard’s env catalog and consumed by the prod requirement check- Two derived halves —
refreshEnvSchema()installs the brain’senvextension,setDerivedEnvShape()installs engine-synthesized entries (notification channel chat ids), so a late brain load can never drop the derived half - Discovery —
resolveBrainRoot(cwd?)(explicit arg →BRAIN_ROOT→process.cwd()),findConfigFile()walking up foromnislash.config.ts, andfindEnvFile()walking up from the config dir so one workspace-root.envserves several brains - Atomic writes — persistent file writes go through
atomicWriteFile{,Sync}; directwriteFileSync/writeFilefromnode:fsis banned by a biome rule with a narrow allowlist, because a truncating write leaves unparsable JSON/YAML and an unbootable brain
- Doctor Check Library — one check library, two consumers, never-throw and never-hang by construction
- CLI (
omnislash doctor) — ~58 checks across Machine (Node, git, Docker + daemon + Compose, pnpm, gh, ports, CLI version), Project (brainpackage.json/.npmrc, CLI-agent.gitignore, G-paths, config, version sync, paths/content, constitution, models, entities, agent roles, hook events, declared grants, dispatch, ingest, skills), Secrets (.env, env vars, Telegram reachability + HITL delivery, LLM providers, CLI binaries/credentials, skill env vars, memory embeddings) and Infrastructure (compose files, infra mode, DB/Redis TCP, memory git sync/index/model, sqlite-vec, MCP + Playwright, sandbox, runIn, transcoding, skill binaries, brain git remote, git identity, artifacts, prod server, deployed versions, repos, scheduled work) - Runtime (
doctor.check— the dashboard’s Doctor page and thedoctor_checktool) — ~40 container-safe checks assembled bybuildRuntimeCategories()from one input builder, so the page and every agent’s tool run the same checks on the same inputs;RUNTIME_CATEGORY_LABELSis the single source of itscategoryfilter —Configuration(the brain’s files and config),Infrastructure(what the deployment reaches) andEvolution(the self-evolving loop’s readiness: its authoring checks,Loop check,Human calibration, and the live cycles’Loop lifecycle/Loop failures, injected from the loop’s control core) - The loop’s authoring checks — one list, two consumers —
LOOP_AUTHORING_CHECKS(eval ceilings, judges, watch / tick / evolve / ceilings / autonomy / groups / proposers / confinement, loop modules, loop role memory, loop kind support) read only the brain’s files and its config, so the CLI (under Project) and the runtime preset’sEvolutioncategory (which the Self-Evolving page reads) run the same list: an overlapping lock group or a ceiling past retention fails on the laptop before a deploy.Loop checkandHuman calibrationread the database and run wherever it is reachable (the CLI included);Loop lifecycleandLoop failuresread the live cycles and are the Self-Evolving page’s alone - Runner guarantees — every
run()is wrapped in try/catch (a throw becomes a correctly-namedfail) and runs under a 10 s deadline (per-checktimeoutMsoverride) whoseAbortSignalchecks must forward, so a wedgeddocker infoor a dead SSH host can never stall the report - Notable checks —
Scheduled work(failfor every enabled scenario or heartbeat whoseschedule:never fires — wording outside the scheduler grammar, or a cron with no valid time — which the scheduler only logs;warnwhenscheduler.enabled: falseholds every clock),Chat access(failfor aconstitution/access.yamlthe bot would refuse — unknown bot, role or command;warnwhen a role with'*'or one reaching the owner’s memory lines is open to non-owners, whenmemberOfnames a chat where the bot is not an admin, and whenengage: alwayssits in a group the bot hears only when addressed — the CLI grades the authored rules, the runtime also reads the bots’ registry),Agent runIn(persistable and available are separate gates, so an unpersistable tierfails and a degradewarns),Agent memory(warnfor amemoryblock on a scenario’s run-scopeagent:, where it is inert, and for areportgrant on a role or brain-wide, where it opens every turn),Context marks(warnfornotifyAt/compactAtonrole-general.mdwhile MANIFEST §contextnames neither — since 1.21.0 that block is the general role’s own, so every other role runs without them; the hint names the move to MANIFEST),Role declarations(a role gets what it declares:warnfor a declaration that cannot take effect — a flush with nothing to flush into, memory tools ormemoryKeepwithout amemory:block, a block without a memory tool, a leftoverpromptFlags.memory, a forked copy of a system role whose template declares memory orskills:(an explicitskills: []in the copy is a deliberate “none” and passes), aconnection:MCP server withoutintegration:execute, askills:name no installed skill answers to, a listed skill that runs a binary (requires.bins/anyBins) on a role withoutengineering:execute, askills:list on an API-resolving role withoutskill:read, a namedskills:list withpromptFlags.skills: harness/both—failfor the flush curator without a block while flush is on, and the inventory of roles running without memory on every verdict),Brain entities,Hook eventsagainst the closed event set (and an inline scenario hook on an event with no run, which never fires),Hook ids(failfor an inline scenario hook and a standalone hook sharing an id — and so itshook:grant scope),Hook gates(a gate that does not load, a gate with no hook),Heartbeat gates(the same for a<name>.heartbeat.ts),Feature contexts(warnfor a role’scontexts:naming no context, a context no role admits and norunreaches,askwithoutdescription, a view two contexts claim, adata:view orbycolumnpubliclacks — the runtime reads the views, a laptop notes it did not),MTProto hook accounts(a hook onmtproto:message.*no authorized Telegram account can feed),Capability names(failfor every capability name a role file or agrants:block spells that the catalog does not know — with the file, and the rename it went through or the nearest real name),Blueprints(failfor every workflow scenario’s.blueprint.ymla run would refuse — the engine’s own verdict: schema, capability names, task rules — each with its file and every fault; plans not written yet are listed),Declared grants,Eval ceilings,Brain G-paths,Brain package.json/.npmrc,CLI agent .gitignore,Code repos,Skill binaries/Skill env vars,Artifacts size,Pipeline modules(CLI: every enabled*.pipeline.tsimported under the discovery rules),Pipeline build(runtime: the pipelines endpoint’s own verdict on its newest build) - Provider liveness is read, never probed —
LLM providersandMemory embeddingsgrade the deployment’s circuit-breaker record on top of their static config walk: the real workload’s verdict, not a synthetic one-token call that would pass during the rate-limit storm killing real batches and be the doctor’s first metered request. Severity is derived from the brain’s ownmodels.jsontopology — every step of a role blocked isfail, a surviving fallback iswarn— so no threshold is chosen and no knob is owed. A closed circuit reports as “no provider failures recorded”, never “healthy” (the key is deleted on success and expires after an hour of silence), andMemory embeddingsneverfails because the memory layer’s own table says the keyword and path legs still answer.omnislash doctoron a laptop reads no circuit record and the checks sayliveness not observed from hereon a green line rather than turning permanently yellow.Memory embeddingslives underInfrastructurein the runtime preset for the same reasonCredentialsdoes — what it grades is a live-system fact
- CLI (
- Restate Runtime as Deployed Infrastructure — a pinned Restate sidecar (ingress 8180 / admin 9070) ships in the infra and prod templates and hosts the durable handlers; in
omnislash devthe app runs on the host and registers overhost.docker.internalvia a staticextra_hostsmapping- Introspection client —
packages/infraships a typed Restate HTTP client (list/query services, handlers, invocations, journal and VO state; pause/resume/cancel/kill/purge an invocation) plus an invocation mirror, so operations tooling never shells out
- Introspection client —
- Stable Error Codes — machine-readable error taxonomies for both surfaces:
OmnislashError(code+hint+toJSON()) for CLI and config, andAppError/AgentError/ToolError/ServiceError/AuthError/DatabaseErrorwith typed codes across the runtime - Graceful Shutdown & Signals —
registerProcessSignalsgives every app one shutdown path, andisTsxHotReloaddistinguishes a watch-mode SIGTERM from a real stop - Dead Code Detection —
knipwith aknip:gateCI step (--include unlisted,unresolved) keeps unused and unresolved code out of the tree
Observability & Operations
- Action Log — who changed or decided what, across every object: one row in
omnislash.audit_recordsper durable decision, with the actor that made it, the person behind it, the op, the object and a one-line summary- Every committed command records itself — an engine op command that committed (dashboard, an agent’s tool, MCP alike) is one row as its caller; a dry run, a refusal or a write that changed nothing is none
- Decisions outside the ops too — a HITL card answered or cancelled in Telegram (
hitl.respond/hitl.cancelas the person who tapped), a run card’s cancel / pause / resume / retry / recover / kill in Telegram (run.<verb>, as the presser), a parent agent’sagent_answerto its sub-agent’s question (hitl.respond), the bot’s own access writes — a served admission (access.widen) and a supergroup re-key (access.update) — and an accept or a revert the self-evolving loop takes on its own (loop.accept/loop.revert— a revert once its auto-merged PR landed, not when it opened); a chat admission card is the access write its answer makes, one row - A loop entity’s history includes its gate — filtering by a scenario, heartbeat or role (or its feature) also returns a person’s answers to that entity’s loop gate cards, beside the loop’s own accepts and reverts
audit_list(governance:read) — newest first, filterable by actor kind, person, objects (targets, any of), object kinds (targetKind, a list), feature (the feature and the entities it ships today), ops (action, a list of ops or<namespace>.*patterns —access.*is every access change, the bot’s own included), a window (windoworfrom/to/tz, defaultall: the log is never hidden by a default window) andq(a part of the summary, the op or the target id; since 1.24.0), paged by passingnextCursorback ascursor; the answer carries the resolvedwindow- Retention — MANIFEST
audit.retentionDays(default 365,0= forever); a failed record is logged and never fails the decision it records
- Three-Signal Stack — logs (Pino JSON → Alloy → Loki), metrics (prom-client per app + exporters → Prometheus), errors (Sentry), all wired into Grafana in the production Compose environment
- Prometheus — scrapes every engine app plus Redis, Postgres, node, cAdvisor, Caddy, Loki, Alloy, Grafana and n8n exporters
- DNS-SD targets — apps are discovered by Compose service name, so BYOI deployments (external
DATABASE_URL→ no managedpostgres-exporter) produce empty target lists instead of NXDOMAIN log spam - Blackbox probes — services without
/metrics(Restate, NocoDB, the Postgres backup sidecar) are probed over HTTP; their alerts key onprobe_successinstead ofup - cAdvisor — per-container CPU/memory/start-time, with per-service restart counting feeding
restartsLastHourand thecontainer-restart-loopalert, and CPU used against each container’scpusceiling feeding thecontainer-cpu-at-limitalert (over 90% for 15m); the restart count folds cAdvisor’s churning container labels away first, viamax by (service, id)under thechanges(). It reaches BOTH daemon sockets —docker.sockas a file, containerd as its DIRECTORY (/run/containerd) — because its docker factory builds a containerd client on any host using the containerd image store, which is Docker’s default since 28. Without that socket the factory never registers and the scrape stays green while the collector exports one series; with the socket bound as a FILE, a containerd restart (anyunattended-upgradesglibc update) replaces the inode under a still-running collector, which then keeps the containers it already had and silently drops every new one. Neither state is silent any more —cAdvisor Blindasserts the positive (count(container_last_seen{name!=""})> 0),cAdvisor Coverage Dropasserts it has not shrunk below 90% of its own 24h peak (counted by container name, on a scrape job that tracks staleness for cAdvisor’s timestamped samples, so a deploy’s recreate overlap is never a peak),health_overview.services.cadvisoranswers “is it collecting” rather than “did the scrape connect”, and a service that isupwith norestartsLastHourwhile others have one is named inhealth_overview.caveatsinstead of reading as observed-and-quiet - Runs in every mode — Prometheus, node-exporter and cAdvisor come up in
dev,startandprodwith mode-specific scrape configs (host-side ports with${…Port}substitution in dev, Docker DNS in start) and lighter retention/memory in dev;PROMETHEUS_URLtells apps where to reach it
- DNS-SD targets — apps are discovered by Compose service name, so BYOI deployments (external
- Application Metrics — eight typed collector families registered per app via DI, on top of Node defaults from
createMetricsRegistry(serviceName)- Tool —
tool_calls_total,tool_call_duration_seconds,tool_errors_total,tool_calls_by_source_total, instrumented inToolsGateway.execute() - Workflow — runs/tasks totals and durations,
workflow_awaiting_duration_seconds,workflow_active_runs,workflow_task_recoveries_total,workflow_queue_wait_secondsand the three cancel metrics - Agent —
agent_executions_total,agent_execution_duration_seconds,agent_errors_total,agent_active_processes,agent_cache_prefix_breaks_total - Planning —
planning_completions_total,planning_duration_seconds,planning_task_count - Infra resilience —
rate_limiter_results_total,circuit_breaker_rejections_total, emitted from the Redis rate limiter and circuit breaker - Connections & OAuth —
connection_token_decrypt_errors_total,connection_revoke_total{outcome},oauth_init_total,oauth_callback_total,oauth_consent_latency_seconds - Published artifacts —
published_artifact_requests_total{handle, variant, outcome}on the/p/*and/a/*serve path; which link was read is a log field, never a label - Brain sync —
brain_sync_last_tick_seconds,brain_sync_interval_seconds,brain_sync_conflicts_total, which is what the freshness badge and the Brain Sync Stale alert read, plusbrain_deploy_drift_secondsbehind the Deploy Drift alert: how longmainhas carried code the running containers lack, counted with the same predicate and the same resolved directory list (brainSync.runtimeOnly) the deploy workflow ignores on, so a memory tick never moves it — and neither does a report an agent drops into a directory the brain declared. It is the only signal that catches a deploy which failed, was cancelled, or never started — the last of which leaves no workflow run and no red check anywhere - Health gauges —
registerHealthMetricsexposesapp_uptime_seconds,app_healthy,app_check_healthy,app_check_latency_seconds, collected on every scrape - Key labels —
service,scenario,context_group,mode(cli/api),provider,error_category,source,category,outcome,handle,variant
- Tool —
- Health & Readiness Endpoints —
createHealthServer(checker, port, log, registry)exposes/health,/readyand/metricson one port for bot, worker, scheduler, mcp, pipelines and brain-sync; webhooks and dashboard expose the same three paths as Hono routes;HealthCheckercomposes named checks (database, Redis) with uptime tracking and maps them to an HTTP status - Service Registry — push-based registry in Redis: each app registers itself with periodic health heartbeats, key expiry means crash detection, and
health.overview’s service roster renders the whole fleet (each service’s instances, last heartbeat and probes) - Bull Board (
monitoringapp) — web UI over every BullMQ queue, on its own Express server with/health,/readyand a/metricsendpoint that recomputes queue metrics on each scrape- Queue metrics —
bullmq_queue_size{queue,state}for waiting/active/completed/failed/delayed,bullmq_job_duration_seconds{queue,status}fromQueueEvents, andbullmq_last_failed_timestamp_secondsso a consumer can tell an old leftover failure from one happening right now (the series is removed when the failed set is cleaned, so a stale age can never linger), andbullmq_jobs_failed_total{queue}counting jobs as they fail for good — theFailed Jobsalert fires on more than 10 of those in an hour, so it resolves on its own once failures stop
- Queue metrics —
- Grafana Dashboards — nine dashboards provisioned as code and auto-loaded by the file provider: Product overview, product metrics, engine internals, log explorer, BullMQ, Redis, PostgreSQL, host system, Caddy
- Grafana Alerting — 46 provisioned rules across
criticalandwarninggroups, routed to Telegram and an engine webhook, all config-as-code (rules.yml/contactpoints.yml/policies.yml)- Rule set — a
*-downrule per service (bot, worker, scheduler, webhooks, mcp, monitoring, pipelines, n8n, restate, nocodb, redis, postgres, caddy, loki, alloy, grafana) plus resource, database, queue, proxy, Loki, event-loop-stall and brain-sync-staleness rules, andPipeline Build Failed(a pipeline edit that did not load while the previous build still serves) - Query-total invariant — the comparison lives in the threshold evaluator, never inside the PromQL, so a healthy system yields a real value rather than an empty vector that would read as permanent
DatasourceNoData - Deployment-optional services —
ALERT_RULE_SERVICE_DEPSmaps a rule uid to its backing Compose service; the CLI drops the rule when the service is absent and tombstones it underdeleteRules, in lockstep with the Prometheus scrape-job filter - Credential rendering —
contactpoints.ymlships__VAR__placeholders thatdeploy.shrenders to literals beforecompose up(the bot token’s names the chief’s bot’stokenEnv, so alerts ride the chief’s bot; no chief’s bot drops the contact point), because Grafana’s own${VAR}interpolation re-infers value types (a negative chat id becomes an integer and crash-loops provisioning) - Notification policy — critical at
group_wait 10s/repeat_interval 1h, warning at1m/30m/12h, grouped byalertname; every severity fans out to both the human contact point and the resolver webhook, withcontinue: truecascading the alert to the resolver route
- Rule set — a
- Alert Auto-Remediation — the
engine-webhookcontact point POSTs every alert, firing and resolved, toPOST /event/grafanawith a bearer secret, and it flows through the generic ingest pipeline (brainextract()→omnislash.external_eventsupsert, which is the dedup → route job →ingest:eventhook → agent + disaster-recovery skill)- What the agent may do — an idempotent auto-fix, a
gh pr create, or a HITL escalation; the route’srepeat_intervalsets the re-summon rate and the brain’s idempotency key collapses duplicate POSTs
- What the agent may do — an idempotent auto-fix, a
- Loki + Alloy Log Pipeline — Alloy discovers containers over the Docker socket, filters by Compose service name, parses Pino JSON, maps numeric levels to text, indexes
levelas a low-cardinality label and attachesmodule,msg,runId,agentId,pipelineRunId,taskRunIdas structured metadata — queryable via LogQL without exploding the TSDB - Structured Logging —
createLogger(module)on Pino everywhere; single-linepino-prettywith system-local timestamps in dev/start (so the CLI’s[service]-prefixed multiplexed boot stays readable) and raw JSON to stdout in prod, with a documented level contract (info= lifecycle milestones,debug= per-boot confirmation noise, and fixed-cadence reconcilers logginginfoonly when a tick actually changed something) - Sentry Error Tracking — initialised per app in
instrument.tsloaded via--importbefore any other code (bot, worker, scheduler, mcp, pipelines, brain-sync); no-op whenSENTRY_DSNis unset,Sentry.pinoIntegrationauto-captureserror/fatalrecords, tracing off, anddataCollectionexplicitly collects nothing (no cookies, HTTP bodies, GenAI prompts or DB queries) health_overviewTool — thehealth.overviewop (since 1.23.0, typedoutputSchema):worst(failing>attention>ok), the process’s health probes, the service roster (upwhile an instance heartbeats, else Prometheus’ scrape; instances, last heartbeat, restarts in the last hour — null = not observed), the brain-sync state (last tick, freshness, bootstrap, conflicts, hints) and eight metric sections (system, database, redis, queues, http, tools, alerts, errors) each{ value }or{ error };sourcessays whether Prometheus and the service registry answered, Loki/Grafana degrade gracefully, and whatever could not be observed is flagged incaveatsinfrastructure_endpointsTool — returns every configured infrastructure endpoint (Prometheus, Loki, Grafana, databases, queues, …) so an agent can discover where a service lives before querying it;restrictedrisk behind theinfrastructure:discovercapability- Provider Health View — the dashboard’s Providers page (
/agents/models/providers, a tab of Agents beside Models at/agents/models) reads the distributed circuit breaker and rate limiter straight from Redis throughinspectCircuitBreakers()/inspectRateLimits()/discoverCircuitBreakerProviders(), answering “is the circuit open right now?” rather than the rate-of-rejections counter Grafana shows; the snapshot refreshes on themodelstopic and polls every 5s (a rate-limit window fills and resets with no event), the event feed refreshes onmodels(every event it lists rings it), cooldowns tick client-side - Credential Inventory — one
CredentialRecordper credential the deployment authenticates with (id, label, group, location, status, expiry, impact, remedy) and never a secret value; a remedy that is done on the dashboard links the row’s own permanent address,/go/credential:<id>(credentialGoPath,@omnislash-ai/types/credential), which opens the page that owns the fix — Connections › Repos for the GitHub lane, Connections › Services for a connection;resolveCredentialInventory(system-tools) concatenates five per-package producers, none importing another- Five producers, one per owning package —
describeGitHubCredentials(the App, its installations, ambient PATs),describeAgentCliCredentials(Claude/Codex OAuth read from the Redis snapshotpipelinespublishes),describeConnectionCredentials,describeEnvSecretCredentials,describeDeployCredentials - Seven statuses,
unverifiedapart fromunknown—ok,expiring,expired,invalid,missing,unverified(no probe attempted),unknown(a probe was needed and could not run) - One
Credentialsdoctor check — a single roll-up over the whole inventory rather than a check per row, fed by a requiredRuntimeDoctorInput.credentialsthunk so an unreachable GitHub costs onewarninstead of the whole report; its hint lists each offending credential with its detail and remedy (command included), so Fix with agent can act on it; the full inventory is/system/credentials credentials.expiryWarningDays— the brain’s seam for how far ahead an expiry reads asexpiring, engine default 14
- Five producers, one per owning package —
- Cost & Usage Ledger — every turn writes its usage segments (per-model tokens, cache-aware cost, billing type) to the spend ledger (
usage_records), the source for theusage_*tools (usage.summary/breakdown/trend/heatmap/tools, which Audit › Cost (/audit/cost), Home’s spend vitals and the run-detail usage tab bind),/statsin chat, the per-run cost column and every evalcost_usd; metered and subscription dollars are always reported apart rather than summed, and a segment with nobillingfield counts as metered by the canonical rule in@omnislash-ai/types/usage - Turn-Ledger Reading Rules — one shared predicate (
TURN_IS_REAL_TERMINAL) excludes the tokens-only row a HITL pause writes from every counter and attribution, with three documented exceptions (duration aggregates, context-policy edge detection, prompt-stability dispatch counting); duration deliberately includes pause time and divides bytimedSegmentsso an expired question cannot print an average larger than any turn that ran - Monitoring Network — a dedicated
monitoringDocker network every scrape-able or log-collected service joins; adding a new service is a documented five-step change (compose network, Prometheus job, Alloy keep-regex, Logs dashboard dropdown,*-downalert rule)
CI/CD
- No per-push CI — the engine repo ships no
ci.yml: local lefthook is the pre-merge gate,Smoke → gatesruns the static suite once a week against the tip, andReleaseruns it in full before publishing unlessSmoke → gatesalready passed on that exact commit- The suite both jobs run —
pnpm turbo type-check lint testwith no--affected, onubuntu-latestwith no persistent cache, followed by the checks below - Repo-wide type-aware lint —
pnpm lint:fullrunsnursery/noFloatingPromisesover everything includingscripts/,test/and root configs; the local gate deliberately skips it to stay at ~12s - Prod-import check —
pnpm check:prod-importsguards what production code is allowed to import - Publish-manifest check —
pnpm check:publish-exportsdiffsexportsagainstpublishConfig.exports, drift in which is invisible to the engine and only breaks brains - Knip gate —
pnpm knip:gatefails on unlisted or unresolved dependencies - Dashboard SPA build — the production image builds the SPA, but no other gate invokes vite, so both gate jobs build it explicitly to catch bundler-level breaks before release
- The suite both jobs run —
- Release Pipeline —
workflow_dispatchonly, refused offmain, never fired by a push or a tag; changesets accumulate onmainand one dispatch releases them all, failing in seconds when.changeset/holds none- Changesets with a 47-package fixed group — every engine package versions in lockstep, so one engine version number describes the whole release;
pnpm cs "<description>" [patch|minor|major]generates a changeset covering all public packages - Version + commit + push —
changeset version, then an auto-commitchore: release v<version> [skip ci] - Publish to GitHub Packages —
pnpm publish -ragainstnpm.pkg.github.comwith a scoped.npmrcwritten bysetup-nodein CI, deliberately not committed at the repo root. No--accessflag: it would OVERRIDE each package’s ownpublishConfig.access, silently publishing a package that declares itself public as restricted - Docker build & push —
linux/amd64image pushed toghcr.io/omnislash-ai/engine:<version>and:lateston a GitHub-hosted BuildKit builder backed by the GitHub Actions layer cache, with aLATEST_TOOLS_REFRESH=<version>build arg that busts the unpinned agent-CLI layer once per release so the persistent layer cache cannot keep serving the versions baked on first build - Atomic release — npm publish and image publish happen in the same job, so a released version always has both
- The version commit rebases onto a moved
main— its push retries up to five times throughgit fetch+git rebase, and publish plus the Docker build then run from the un-rebased gated commit - Docs published to omnislash.ai — a
continue-on-errordocs-syncjob (its own workflow, called by every release and dispatchable for one version) rsyncsdocs/brain-dev/*.mdplus an.engine-versionstamp intoomnislash-ai/website’ssrc/content/docs/under a website-only PAT, alerting Telegram on failure
- Changesets with a 47-package fixed group — every engine package versions in lockstep, so one engine version number describes the whole release;
- Packages Retention Workflow — a Sunday 05:00 UTC (plus
workflow_dispatch) sweep,scripts/prune-package-versions.ts, deleting every GitHub Packages version of each published package past the newest--keep(default 10), with--dry-runlisting instead of deleting--protectexempts versions from the sweep — the exact versions live brains pin, named on the job; deletion is permanent and npm never republishes a deleted version number- The retention floor is a published contract —
docs/brain-dev/contract.md§ 1.1 states the last 10 releases stay installable, roughly a week of history at the current cadence, so a pin older than the floor failspnpm install --frozen-lockfilewithERR_PNPM_FETCH_404
- Changeset Bump Workflow — a
workflow_dispatchform (description + patch/minor/major) that runs the changeset helper, pushes a branch and opens the PR throughgh, so a release can be cut from the GitHub UI without a local checkout - Weekly Smoke Workflow — a Saturday 06:00 UTC (plus manually dispatchable) suite that no PR gate covers, with Telegram alerting on every failure
- A tip already smoked is skipped — a
changedjob comparesgithub.shaagainst the last completed run’shead_shaand drops the scheduled tick on a match; aworkflow_dispatchalways runs - Docker runtime smoke — one build of the real engine image boots all seven services (bot, worker, scheduler, mcp, webhooks, pipelines, monitoring) in turn against live Postgres and Redis with the image-baked
brain-exampleasBRAIN_ROOT, hard-failing onERR_MODULE_NOT_FOUND/Cannot find package|modulein the startup logs - Dashboard E2E — migrates, seeds the layout-breaking fixture, builds the SPA, starts the server, waits on
/health, then runs the dashboard smoke against it and the Playwright specs, and uploads the shots, the report and the server log on failure - Integration suite — the full
*.integration.tsset on Testcontainers-provisioned PostgreSQL, Redis and Restate with both migration lines applied; nothing else runs it pre-merge, so the weekly heartbeat is what catches drift
- A tip already smoked is skipped — a
- Generated Brain Deploy Workflow — the engine renders a service-agnostic
.github/workflows/deploy.ymlinto brain repos: run the brain gates, resolve the engine version from the@omnislash-ai/clipeer pin, build and push a GHCR image with the version as a build arg, then — once the gates pass — SSH to the host and delegate to the engine-shipped.omnislash/deploy.shpaths-ignorecarries the deploy-skip — a push that only moved runtime-only content never starts a run, so brain-sync’s ~5-minute content tick cannot recreate containers mid-conversation. The filter is rendered from one resolved list: the engine tier (**.md,memory/**) plus every directory the brain declares inbrainSync.runtimeOnly(an Obsidian corpus,people/, agent reports — G-directories the engine never loads, so the.html/.jsonan agent drops there is live the moment brain-sync writes it). The same list drives theDeploy Driftalert, so declaring a directory once keeps both in agreement; never add a pattern to the YAML by hand. Keyed on the diff of the whole push, not on a commit-message marker: a marker is read off the pushed tip only, so it skips real code sitting underneath it- Deploy waits on the gates — the first job is the same gates job
ci.ymlruns (one renderer,gates-job.ts), anddeployneeds both it andbuildin the same run, so the SHA that was gated is the SHA that was built and deployed; a manualworkflow_dispatchis gated the same way, and nothing depends onci.ymlexisting or finishing.buildruns in parallel with the gates (a red commit’s image is pushed, never deployed). The concurrency lock stays workflow-level so runs deploy in push order — a job-level lock would let a later run’s faster gates win and the earlier run then deploy older code over newer. No skip-gates input; a deploy a red gate blocked is whatDeploy Driftreports. An owned (.override)deploy.ymlmust be re-based to get the gate - Engine fixes propagate without regen — deploy-flow changes ride the peer-pin bump that rewrites
deploy.sh; only GitHub Actions primitive changes need a YAML regen - No credential on a command line — the deploy parameters, GHCR token included, reach the host on the ssh command’s stdin as a marker-closed
NAME=valueblock, never on its command line: that line is argv of a real process on the server andpsshows argv to every local account for the whole deploy. No${{ }}expression is spliced into arun:script either, andDEPLOY_HOST/DEPLOY_DIR— hand-editable repository variables that both reach a shell — are charset-checked by the job’s first step. A caller that predates the stdin contract is told to runomnislash upgradeinstead of deploying half-configured - The host is pinned, never scanned —
Setup SSHwritesknown_hostsfromDEPLOY_HOSTand theDEPLOY_HOST_KEYrepository variable (the host’s own/etc/ssh/ssh_host_ed25519_key.pub, read off the host byomnislash init-serverand set alongsideDEPLOY_HOST) and runs withStrictHostKeyChecking yes; a fresh runner per deploy had madessh-keyscana trust-on-first-use with a new first use every run. A missing or malformed pin fails the job’s first step naming the fix;init-serverprints the key’s fingerprint so the first contact can be checked against the provider console
- CI Workflow — the engine renders
.github/workflows/ci.ymlinto brain repos: onegatesjob (shared withdeploy.yml) that installs with the same registry auth and runs the brain’s owntype-check/lint/testscripts between two engine-owned steps —Upgrade taskfirst (fails whileupgrade/TASK.mdhas an unticked before-deploy or in-repo entry),omnislash doctor --check pipelineslast — on pull requests intomain, on a dailyscheduleand on manual dispatch. No push trigger — Deploy runs the same gates on every push tomain, so a push run would gate the same SHA twice — and nopaths-ignore: a skipped workflow reports no status and would hang a prose-only PR behind a required check. Config-blind: nothing in it derives fromomnislash.config.ts. The scheduled tick re-runs the gates against a tip nobody pushed — what catchesmaingoing red from a dependency or registry change rather than from a commit. Built byci-yml-builder.ts, sentinelci.yml.override