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 corpus omnislash upgrade copies verbatim into every brain’s docs/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 in packages/cli/src/docker/engine-docs-builder.test.ts fails 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
  • 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 — ProjectConfig is public, committed configuration; secrets/private/infrastructure values live in .env and are read via env.* directly in packages. The config file must never import env
    • Brain-declared env schema — defineProject({ env: z.object(...) }) merges the brain’s Zod shape into the engine’s at load time, so validation, getEnvCatalog() and checkProdEnvRequirements() all see the combined shape; .meta({ category }) groups brain vars in the dashboard catalog
    • Function-carried fields — tools, ingest.sources, bots[].policy, kpis[].read carry brain code by reference (file mode only; the function itself is never validated — kpis is the one whose declaration around it is) and may read env inside their bodies even though the config module may not
    • Fixed dirs are convention, not config — features/, constitution/, skills/, memory/, constitution/models.json derive from the brain root; defineProject takes no paths key (passing one throws with a pointer to features/<feature>/)
  • Display formatting for people — formatInstant (Oct 12, 20:17 GMT+2, the brain’s locale.timezone), formatDecimal (2.33, 0.004, 12,400) and formatUsd ($7.50, $0.0042 below a cent) from @omnislash-ai/config/public: the one spelling the engine uses in every chat notice, card and reason, so a brain’s own reason text reads the same
    • Per-service toggles — services: { bot, worker, scheduler, mcp, webhooks, monitoring, pipelines, dashboard, caddy, nocodb, n8n, playwright, browser, brain-sync }, all default true; a disabled service drops its compose template, its depends_on references, 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 stable OmnislashError code + 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 validated env proxy, 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 in validateRuntime(config), which the shared app-boot ritual bootstrapEngineApp runs immediately after the load. That split is what lets omnislash doctor DIAGNOSE a broken environment instead of dying of it, and lets regen / upgrade regenerate artifacts in a CI job that carries no brain secrets at all
  • G / R / W Path Classification — every path under /app/brain is 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.runtimeOnly names 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 .md plus memory/; resolveRuntimeOnlyDirs(config) is the one list the generated deploy.yml paths-ignore and brain-sync’s deploy-drift clock (BRAIN_SYNC_RUNTIME_ONLY_DIRS) are rendered from (ci.yml has 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/). The Workflow filters doctor check catches an owned (.override) deploy.yml whose hand-edited filter drifted from the list; Owned workflows warns 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_PATHS rejects 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; assertGPathValid throws at defineProject() and the Brain G-paths doctor check fails
    • Materialization guarantee — brain-sync mkdir -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.ts or a new install[] spec in a SKILL.md needs omnislash build
  • Brain Runtime Persistence (brain-sync daemon) — a dedicated long-running service is the single owner of brain git state, keeping the G-set synchronized between the brain repo and the brain_state volume
    • Bootstrap — git sparse-checkout set --no-cone with anchored G-path patterns, shallow fetch, checkout -B <branch> origin/<branch>; a poisoned or corrupt .git is rebuilt in place — whatever the worktree holds beyond origin is pushed to a brain-sync/recovered-<time> branch to merge, and the worktree is reset to origin; on an existing volume the checkout’s origin is reconciled against BRAIN_GIT_REMOTE first, 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 keepRecentHistory deepens the shallow brain_state checkout to the last HISTORY_WINDOW_DAYS (30) via deepenHistorySince (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; one pull --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 with cannot pull with rebase and 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 to BRAIN_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:completed pub/sub pair driven by the brain.sync op — its brain_sync agent tool and the dashboard “Sync now” button (outcome, durationMs, conflicts; skipped when 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 healthcheck start_period bounds 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 AbortSignal so the volume is never left mid-rebase
    • Observability — brain_sync_last_tick_seconds, brain_sync_interval_seconds, brain_sync_conflicts_total{direction} plus app_check_healthy{check="brain-sync-bootstrap"|"brain-sync-staleness"}, surfaced through health.overview’s brainSync (the dashboard’s sync badge, an agent’s health_overview)
    • Git identity — GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL are 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) by applyGitIdentityDefault, 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 into GIT_COMMITTER_* so every git invocation, including bwrap’d agents, has a complete identity
  • AI Conflict Resolution (supervised episode) — a rebase conflict exports the daemon’s unpushed commits to origin and spawns a system:git-conflict-resolver agent 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, ResolverReportSchema structured output) with the exact range to replay: base..brain-sync/conflict onto origin/<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 pushedTip must be a push made after the export (a strict descendant of the origin/<branch> the resolver was handed) that sits on the freshly fetched origin/<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-sync mounts 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
  • Content Hot-Reloading (ContentWatcher) — a generic node:fs.watch wrapper 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 — worker reloads capabilities / tool policy / planner methods / loop prompts (+ skills) and republishes the tools a widget page’s call runs on, mcp additionally republishes the tool catalog and fires MCP notifyListChanged, chat-core reloads capabilities + tool policy + skills, pipelines adds a filtered *.pipeline.ts listener that rebuilds and re-registers Restate services, webhooks reloads capabilities + tool policy for the tool door it hosts, dashboard reloads capabilities + skills (it holds no tool registry, and its tool lists come from the Redis catalog mirror), scheduler pokes 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.json keeps the previous valid policy and logs STALE rather 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, in peerDependencies, at an exact X.Y.Z; any entry in dependencies/devDependencies produces 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 own NPM_REGISTRY (empty under public npm). Never an auth line: a committed _authToken=${NODE_AUTH_TOKEN} outranks ~/.npmrc and 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-node in 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 structural Brain package.json check, and the assertEngineSingletons() boot probe in bootstrapEngineApp() (production only) that fails if any @omnislash-ai/* resolves back inside the brain root
    • Lockfile decoupling — the brain commits pnpm-lock.yaml for 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
    • .gitignore discipline 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
  • 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 a mkdir + commit, no image rebuild unless a new npm dep appears
    • constitution/ is configuration only — MANIFEST.yaml, models.json, the capability/tool JSONs, PHILOSOPHY.md and system prompts; entities there are not loaded
    • Misplaced files fail boot — defineProject refuses to start when any entity or feature file (.scenario.md .heartbeat.md .hook.md .agent.md .pipeline.ts .blueprint.yml .loop.ts) sits under constitution/ or loose in features/, listing every offending path; the Brain entities doctor check reports late arrivals as [misplaced]
    • Per-feature pipelines — a scenario owns its *.pipeline.ts files 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 doctor Brain 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’s constitution/
    • 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: hook 1000ms, 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.md whose frontmatter stops parsing is repaired in place by EntityRepairer on boot and on every content tick, announced to the status channel so the diff can be reverted
  • Entity vocabulary — a feature ships four entity kinds, scenario · heartbeat · hook · role (a *.agent.md), and every engine surface names one as kind:id — split on the FIRST :, so role:system:loop:proposer is the role system:loop:proposer (@omnislash-ai/types/entity: ENTITY_KINDS, EntityRef, formatEntityRef / parseEntityRef, LOOP_TARGET_KINDS, ActionTarget for what a command acts on); how one is doing is the health set off · ok · attention · failing plus 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 together
    • FEATURE.md — optional features/<id>/FEATURE.md with strict frontmatter name?, description?, icon? (a Lucide icon name as lucide.dev writes it — chart-line, users — from the dashboard’s curated set, which feature_set_icon lists in its value enum; the dashboard ships lucide-react 1.47; never an emoji; no icon draws none), enabled? (default true; an unknown key such as enable: 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 an evaluation: block (evaluated) and the files each owns; a scenario’s inline hooks included, each a hook of 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 (inbound event source, service OAuth provider or integration, bot-policy, tool, lifecycle, else module), files that do not load, entities in other features that use its roles, the FEATURE.md sha; no run state — how they run is health_list), feature_trend (feature:read, since 1.24.0: one feature’s runs, failed runs and its agents’ chat turns per hour or day over 24h, 7d, 30d or any window — the chart the Features page opens from a feature’s week), feature_read_file (feature:read, since 1.23.0: the FEATURE.md as written with its sha — empty when the feature has none yet), feature_set_enabled / feature_set_icon and, since 1.23.0, feature_write_file (the whole FEATURE.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 stale expectedSha is refused as conflict, a result that would not load as invalid, and the file keeps every comment. Since 1.25.0 a feature also lists its widget files (widgets: each page’s ui:// address, sha and the .widget.yml beside it), and feature_read_file / feature_write_file take a widget file’s path to 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, …, or from / to; feature narrows it to one, engine the built-in one), per feature and per entity it ships: what it ships by kind (ships); its weight — 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 as hitl_summary attributes them — what the Inbox counts); and what its runs and sessions cost. An entity that did nothing is left out of its feature’s entities; totals is 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_list also answers a built-in feature, id engine (builtIn: true): the engine’s system: roles as its entities, the system role files that do not load as its broken files; it has no folder, no FEATURE.md and no switch (feature_set_* / feature_write_file refuse it). Every attribution reads it the same way — a system: role’s agents, runs, spend and requests belong to feature engine. Its shared instruction modules are instruction_list / instruction_get (feature:read) and instruction_write_file / instruction_reset (role:write: the first save copies the engine template into your constitution/, a reset deletes the copy) — the self-evolving loop’s prompts among them since 1.24.0 (loop/… ids for constitution/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 are schedule_overview’s
    • Reserved feature id (since 1.23.0) — engine is reserved: a features/engine/ folder is not a feature — each entity file in it is skipped naming the folder, defineProject refuses to boot on them, and doctor Brain entities names the folder ([Reserved feature id]). Rename the folder
    • Effective enabled — each entity runs only when its own frontmatter enabled (default true for every kind) AND its feature’s enabled are true; entities carry feature, declaredEnabled (the file’s own flag — what entity toggles write) and enabled (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.md in a switched-off feature resolves like a missing role; the Agent roles doctor check flags enabled entities that dispatch to it
    • Hot — FEATURE.md is 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.md holds, not drops — an unparsable file makes every entity in the feature an unparsable skip 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 · failing plus 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, the health.entity / health.list ops; since 1.23.0 one health_list call answers every feature, or one with feature, and each entity carries its trigger in words with its next fire and its ten newest runs as outcomes, newest first, each late when a clock-started run started past its grace, and how many tool calls failed on its newest run (lastRunToolErrors); feature_list carries no run state since) — first matching rule wins: Off (switched off, its feature off, the scheduler stopped or a disabled schedule — 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 like 0 9 31 2 *), a hook gate that threw, failingAfter failed runs in a row, Down: downAfter expected 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 of failingAfter, tool failures on the last run, a run queued / running / retrying past stuckAfterMs, 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.ts that 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 carries blockedBy — the provider: of each open circuit on its model chain and the credential: 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 (a FEATURE.md that does not load, a stray pipeline file), always with the counts in words (1 failing · 2 need attention · 4 OK). Infrastructure health is health_overview
    • Summaries (since 1.23.0) — run_summary adds onTime (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 with evals, the window’s graded and passed counts it is a share of), errorClasses (failures per executions.error_class, unclassified for none) and, with by: "feature" | "entity", groups with the same figures and each group’s newest failure; hitl_list takes feature / 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; a scope: chat hook’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_trend take the feature dim (the feature shipping the row’s entity now, else its role’s; a system: role or one no feature ships is engine), grouped before the top-N fold
    • The windows are yours — constitution/MANIFEST.yaml § health for 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, its FEATURE.md or 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) => void registrar, stamped source: 'project'
    • Integrations — integrations: [defineIntegration(...)], tools + webhooks in one bundle
    • Lifecycle hooks — lifecycle.onStartup / onShutdown with a per-service LifecycleContext
    • Ingest sources — ingest.sources: [defineEventSource(...)] for provider-agnostic inbound events, each optionally carrying its own verify credential check in place of the shared WEBHOOK_SECRET, its own body cap (maxBodyBytes) and per-event resources; 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 own timeoutMs (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) and integrationsStore.additional (extra custom rows)
  • 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 synced docs/engine/ tree, docker/compose.{prod,start}.yml.example, and the mirrored skills/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-version and skills/engine/.engine-skills-version carry the engine version so drift is detectable and self-healing
  • Brain-Facing Docs Corpus — docs/brain-dev/ is a self-contained doc set carried inside the @omnislash-ai/cli tarball and synced verbatim into every brain’s committed docs/engine/ — first by omnislash init, then by every regen / upgrade; getting-started.md is its front door and the file doctor hints name, and sibling-only links are enforced by a link-check test
  • Brain Scaffolding — omnislash init takes 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 .npmrc whose registry scope derives from the engine’s own NPM_REGISTRY constant and which carries no auth token
    • The engine-owned tree — NOT templated: init installs the pinned engine and then spawns the brain’s OWN omnislash regen, so a --version-pinned brain is never stamped by whichever CLI ran the command
    • The dotenv pair — .env and .env.example are generated from the live env catalog, never templated, so they cannot drift from the schema the engine validates against
  • 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 sessionId resume, 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 result that arrives with no live background task (the harness’s background_tasks_changed set, minus its ambient housekeeping entries). A backgrounded shell, harness subagent or Workflow run therefore finishes inside the turn and the CLI wakes the model with its notification in the same turn (several results 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 one AGENT_BACKGROUND_HOLD_TIMEOUT_MS total; 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_events every turn), tools injected directly as a capability-filtered ToolSet, 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 and MEMORY.md in a standing system message and the daily window / state in each turn’s message, never in the cached system prompt; TTL promptCache.ttl (engine 1h → MANIFEST.yaml → role)
    • fake harness — a test/demo harness programmed from the prompt itself with <fake>{…}</fake> directives (text/thinking/tool/sleep/complete/fail/askQuestion/requestGrant/hang/awaitSignal steps, named transcript fixtures, synthetic usage, structured output matching jsonSchema), 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 one CliStreamEvent union 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
  • 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 enqueue handler holds the per-key lock across the entire session including a human wait of arbitrary length, so a pipeline callbackId never leaves handler scope and settles exactly once
    • agents row is a projection, not a control surface — status / error / currentInvocationId / currentAuth / currentInputIds are 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 the Actor that created it (spawning agent, heartbeat, hook, workflow run, pipeline, planning, evaluation, loop, chat, dashboard, system); AgentCreateParams.createdBy is required on every create, NULL only on older rows
    • Zero zombie runs — reconcileAgentOrphans (pipelines startup + scheduler 5-min sweep) cross-references each busy agent’s currentInvocationId against sys_invocation and routes every settle through the VO’s EXCLUSIVE reap handler, 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 — settleRunlessTerminal announces 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’s inputIds so 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 lock
    • agent.call(input, opts?) — programmatic request-response awaiting the terminal AgentExecutionResult; 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 s headersTimeout and 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-arg run handler; callback-/schema-free by type
    • agent.sendCompact(opts?) — the chat /compact: a compact entry in the same inbox, at the tail (queue mode) or — with interrupt — at the head after preempting the live turn; served alone as the compaction turn, the outcome on its turn.completed
    • agent.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 exactly send
    • agent.stop() — cancel the live Restate invocation; the VO’s catch aborts the run, settles the pipeline callback and projects cancelled
    • resume — deliberately not a facade method: the HITL gateway reads resumeAwakeableId from 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, enqueue only), 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 call
    • dispatchAndObserve — dispatch-then-observe helper shared by planner stages and the recovery boss; an already-aborted signal dispatches nothing and returns cancelled instead of minting a turn only to kill it
  • Durable Chat Inbox (Mailbox) — agents.pendingInputs is the chat’s durable, ordered queue of operations (messages, harness slash commands, and a queued /compact), served by the VO’s run handler 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, not failed — interruptedByShutdown rewrites only a failed terminal, and only while isShuttingDown() is latched, so no failure bubble is posted and anything not yet delivered stays owed; a boot-time sweep then pokes run once per agent still owed a turn
    • Merge semantics — nextMailboxTurn takes the inbox head: consecutive messages merge (bodies joined with \n\n---\n\n, attachments FIFO, last runOptions wins), 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 → stopAgentDiscardingInput drops input a turn claimed but has not delivered yet (dashboard Stop, /agents stop button, agent_stop tool); /stop clears the whole column; interrupt preempt, flushMailbox and recovery use the bare stopAgent
    • 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-limiter VO 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_input pause 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 acquiring invocationId (the recovery key), so purge(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.spawnedByAgentId and takes a non-blocking permit, so a permit-holding parent can never deadlock on a permit-needing child
  • 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) across content.delta, item.*, turn.*, tool.progress, task.*, hook.*, hitl_*, session.cleared, model.rerouted, runtime.warning|error|retry, each carrying eventId / 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 an AgentStreamTerminal — a Pick of AgentExecutionResult that deliberately carries no output text
    • Consume modes — consume() is ephemeral (auto-stop at the first turn.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-resumable streamOutput({ afterId }) on a dedicated blocking connection, and streamOutputMany multiplexing N agents into one XREAD for the dashboard matrix
    • PG↔Redis checkpoint — every event’s Redis stream id is stamped onto its conversation_events row, so a snapshot page hands the client an exact lastEventId and the live tail resumes with no gap and no duplicate
    • Cross-process waiting — waitForStateChange combines the pub/sub bell (fast path) with a periodic durable re-read of the row (correctness floor under an at-most-once bell)
  • 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 through ConversationService
    • run.emit() 3-way fan-out — Redis stream write (returns the streamId) → PG persistence carrying that checkpoint → derived EventBus notifications
    • Persisted vs transient — CONVERSATION_EVENT_TYPES decides what lands (item.completed, turn.started|completed|plan.updated, hitl_*, session.cleared, model.rerouted, runtime.*, task.completed, hook.completed); deltas and item.started collapse into their terminal row
    • Tool-call pairing — item.started is held in memory until its item.completed lands, then persisted enriched with toolName / toolInput / toolCallId
    • Monotonic id ordering — 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 rides conversation_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_id column holds the primary harness’s session (config.ref step 0); a failover step runs with ownsSessionSlot: false and can never stamp its id over the primary’s (the cross-harness session-poisoning fix)
    • session_not_found self-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
    • /clear is a rotation — the live conversation is archived, a fresh one is created with a session.cleared back-pointer, the inbox drains and the agent:cleared bell reaches every chat where the agent was active
    • Threshold summarization — summarizeIfNeeded folds the prefix into conversations.summary past conversation.summarizeTokenThreshold, using the brain-defined summarization prompt and the fast chain, 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; with wait it returns a CompactionResult reporting the conversation rewrite (with before/after token sizes) and the session separately, since they succeed and fail independently — also carried on the turn’s turn.completed; interrupt and wait are independent knobs, both default off
    • Compaction markers — context_compaction items narrate a compaction on the stream (item.started → terminal pair for the compaction turn, a declined marker 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 no user_message row, 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 /compact in prose; every other turn — chat, enqueue, call, heartbeat — is a conversation turn, and the chat inbox produces one only from a typed compact entry, never from a message
  • 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.context against 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 own context: 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_threshold announces 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 /compact typed in reply would target a different one; an ordinary sub-agent runs surfaceless, so its nudge reaches no chat
  • Context Placement (cache-prefix stability) — what goes where is decided by volatility and agent-independence, never by topic, because Anthropic invalidates tools → system → messages top-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_found self-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) — applyPromptCache marks each API chain step in its own cacheDialect: cache-control on Anthropic native and vercel:anthropic/*, breakpoints on OpenAI GPT-5.6+, none elsewhere; TTL 5m | 1h, engine 1h → MANIFEST.yaml → role promptCache:
    • Preset-side knobs — the Claude runner unconditionally pins excludeDynamicSections, CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 and CLAUDE_CODE_TOTAL_TOKENS_REMINDER=off, which is what lets a fleet of same-role agents share one prefix, and CLAUDE_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 a memory: block has none
    • Break detection — agent_cache_prefix_breaks_total{level} compares hashed tools / system prefixes across consecutive turns per (agent, harness) and warns when somebody else’s build moves them
  • 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/list bytes for a CLI delivery, the narrower AI-SDK projection for API), the system-prompt cascade attributed section by section from PromptSection[], and the context envelope by named part
    • promptedTokens vs deferredTokens — 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 counted unmeasured, 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 its tool_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 — registry or observed — an API delivery reads contextWindowTokens off models.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-unknown splits on the harness reportsContextWindow flag
    • 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 by findMeasuredPrefixes) — so the harness’s own preset prompt and builtin tools, which no estimate sees, show up as outsideEngine (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 the state.chars budget cut or stubbed), memory-heavy, role-heavy, window-unknown, mcp-servers-unavailable, and the footprint flags wildcard-capabilities / wildcard-skills ('*'), memory-unused (memory declared, no memory tool called), grant-pinned (live agents of the role holding capabilities beyond its file) and unconfined-builtins (a Codex / OpenCode ref whose own tools ignore the grant); every threshold is the role’s resolved context: config (engine → MANIFEST → role)
    • Usage beside the footprint — agent.context (tool agent_context, runtime:read) answers each role’s report with its runs, the tools they called and the skills they opened (skillsOpened, beside skillRuns — 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), from usage_records by role — runs counted as usage_breakdown counts turns, tools by the bare name usage_tools names them, spend left to usage_breakdown by 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 (tool role_trim, runtime:read) previews a role_update patch: the skills: list narrowed to the skills the role’s agents opened (recorded per turn in usage_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 for toolLoading.eager, the diff and the tokens every turn sheds; it writes nothing — the patch commits through role_update against 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 raises context-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 a Context budget doctor 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 past toolLoading.deferAbove behind tool_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 or toolLoading, a narrower capabilities: grant or constitution/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 own promptBudget:, 0 = off): a role body over it raises role-heavy on /system/context with its three largest ## sections, and doctor Context budget warns naming it — observation only, never a failure. Writing guidance: role-prompts.md
  • System Prompt Cascade — one composeSystemPrompt builds 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_MS rendered 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 the AGENT_BACKGROUND_HOLD_TIMEOUT_MS total (omitted when the idle watchdog is off). Both halves carry two tool bullets that follow the grant: “come back later → schedule_create_prompt” only with schedule:write, “too long → agent_task / agent_create + agent_enqueue + agent_await” only with agent:execute and runtime: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 unlisted agent-state engine skill for the long guide — present only when the agent can load it (CLI always; API with skill: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 of promptFlags
  • 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; false drops the list, never the request tool), skills, language
    • skills is three-valued — 'engine' | 'harness' | 'both' decides which of the two skill systems delivers what the role’s skills: list grants on a CLI turn (the engine’s policy-gated catalog vs the harness’s own Skill tool and self-discovered .claude/skills/), because “both catalogs live” must be a choice rather than an accident; there is no off value — no skills: list is no skills (a stale false fails the role load)
    • Memory and skills are declarations, not flags — what an agent remembers is the top-level memory: block, which skills it sees the skills: 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 no memory: 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
  • 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); a role-*.md under any other id is skipped and named by Brain entities (with the rename when the id was renamed — a stale role-browser-operator.md or role-dashboard-guide.md). Namespaces are subdirectories: each : after system: in a role id is one directory under constitution/Agents/system/, so system:loop:proposer is loop/role-proposer.md and a brain overrides it with its own copy at constitution/Agents/system/loop/role-proposer.md (a role-proposer.md directly under system/ is a different role, system:proposer)
    • Brain roles — user-defined personas as <id>.agent.md files 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:general frontmatter → role → the agent’s own overrides; nothing is pinned: the model and the grant resolve from the role file on every turn (capabilities is the exception to the ladder: the role file’s own list, never role-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 (modelRef at 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_update edits a role file (brain role in place; a system: 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 money budget:, which is budget_update’s (all three role_* writes refuse the key); role_create makes a new features/<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 in MANIFEST.yaml that 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, is role_widen (role:widen, a person approves) — role_update / role_create refuse 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.0 role_write_file replaces a role file whole (the raw editor’s save, held to the same checks; a widening is role_widen with content), role_reset (role:widen — the template may grant more) deletes your brain’s copy of a system: role so the engine template serves again
    • Role reads (since 1.23.0) — role_list (feature:read) lists every role, the engine’s system: roles included (feature engine), each with its source (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, an access.yaml route, a secretary counterpart); role_get adds its frontmatter as written, body, {{placeholders}}, path and sha (forked = the text is the engine template and the first write copies it), or broken for a role file that does not load (its id and source answered either way); role_read_file (feature:read) is the role file as written with the sha role_write_file takes (for a system: role you never customised, the engine template — forked)
    • Picker reads (since 1.23.0) — model_list (config:read) lists every ref a modelRef: may take that can dispatch now: the models.json role words (API first, then CLI), each cli:<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 chains defineProject declares — 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, with loadsObserved: false when no turn that could have shown an open ran), plus the lockfile entries missing on disk
    • Role handlers (per-role extension point) — an AgentRoleHandler can be registered against any role (registerRoleHandler) to install a gate() that short-circuits a turn before any cost; the boss handler skipping a stale wake-up returns a quiet cancelled that is still announced on the stream
  • Playwright MCP Browser — headless browser as a sidecar MCP service: agents get navigate, click, read, screenshot across all CLI providers; toggleable via services.playwright
  • Browser Role — engine-shipped system:browser with the browser pre-attached and an anti-prompt-injection prompt; any agent spawns it with agent_task, engine code with Agents.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 omnislash MCP 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.md frontmatter mcpServers < the engine-managed omnislash entry; an agent-declared omnislash key is dropped with a warning
    • Per-harness delivery — Claude via the SDK mcpServers argument, Codex via the in-memory CodexOptions.config, OpenCode via OPENCODE_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 of connected at session start is warned by name
  • Structured Output — Zod-schema JSON responses from any agent, alongside plain text, persisted on the terminal turn.completed and mirrored onto the agent row
    • Runtime normalization — normalizeOutputSchema strips every constraint no strict provider accepts, forces additionalProperties: false on 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
  • 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) and failover (worth the next step of a fallover chain). It covers timeout, rate_limit, session_not_found, bad_request, authentication_failed (401 / invalid key / expired token) and billing_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 apiChains you declared in constitution/models.json behave 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 retryable timeout, killing a wedged CLI harness or aborting a silent API stream and releasing its AGENT_EXECUTION_CONCURRENCY permit. A Claude turn held open by live background tasks is silent by design, so while the harness reports live work the budget in force is AGENT_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 retryable timeout when 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. A result with a HITL pause armed ends the turn regardless of live tasks — a paused turn keeps the shape it always had
    • In-process backoff retry — retryWithExponentialBackoff re-runs a step and banks each discarded attempt’s usage segments so burned tokens still reach Audit › Cost
    • Resilience events — model.rerouted (with fromModel / toModel / reason failed|circuit_open / the real triggering error) and runtime.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 error part is terminal and rides the result into one resolveOutcome, so a dead turn is failed with classification instead of completed with 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.toolCalls counts calls per tool name (keys in first-call order) at emitDerivedEvents, the one choke point both CLI and API tool results pass; runDispatch derives the result’s toolsUsed and hasToolUse from 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 a finally
    • 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 sessionId resume already carries the history
  • 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 — voFinalizeStages writes no ownership state; the VO owns status, the row projection and the lifecycle signal
    • Brain extension point — AgentExecutorDeps.extraFinalizeStages run after the defaults so they observe fully committed state
  • Per-Call Run Options — a single dispatch can override provider and prompt behaviour without touching the agent row
    • CLI knobs (opts.cli) — webSearch (false drops Claude’s WebSearch — it never widens past research:read / Codex webSearchMode), 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’s skills: and agent_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’s runAgent / 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’s AuthContext), runIn
  • 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 op agent.create, agent:execute: a role, an optional name, model, lineage, repo, runIn and first task; ifExists: 'reuse' with a name makes it get-or-create — the live agent of that name is answered as it is (reused: true) instead of refusing the clash; dryRun: true answers what the agent would get and which runIn rungs 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 on awaiting_input like agent_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…) and agent_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 op agent.list: one row per session — or per lineage / run with group — with its role’s feature, 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 and origin; the Boss session and live ones lead every sort; filters by status / word (what the rows say, Stuck included) / source / role / feature / lineage groupKey / name q / run / entity / parentId / one bot’s chat / intercepted / a window, deleted: true for soft-deleted ones; keyset pagination; counts: true — how many rows each word lists under the same filters), agent_summary (the op agent.summary: counts per status and, with words: 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 with role, and with by: 'role' each role’s sessions and newest activity), agent_get (the op agent.get, by id or name: 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), partialUsage and a stream cursor — the messages scheduled to it and the resource watches it holds), agent_history (the op agent.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 holding hitl:read), agent_tail (the op agent.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 with hitl:read); all the reads are grant-free (runtime:read) — a request’s content is hitl: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.yaml sessions.stuckAfterSeconds → a role’s own sessions: { 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.continueAfterRestart times (engine 3 → MANIFEST → role; 0 = never), then ends it interrupted and says so in the chat; brain-sync holds a push that would deploy while a turn runs, up to brainSync.deployHoldMinutes (60); a turn card the restart left closes at the turn’s end
    • MCP agent resources — omnislash://agent/{id}/status and omnislash://agent/{id}/output expose the same reads as MCP resources, scoped to the caller’s spawn family (the created_by tree: its root and every live descendant — a parent sees its children, a child its parent and siblings)
  • 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 groupKey makes the agent its own lineage (groupKey = name); at least one of name / groupKey must 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_task is its own lineage, so a live parent never disappears behind its running child; who spawned whom is created_by, and the family (MCP resource scope, retention) is that tree
    • Per-instance meta — agents.meta JSONB replaces the dropped agent_boss_runs and heartbeat_runs tables (meta.boss, meta.heartbeat), with a partial unique index on meta->'boss'->>'status' enforcing the single-active-boss invariant
  • Agent Retention & Reaping — a periodic sweep in the pipelines service (6 h) soft-deletes idle transient agents and hard-deletes expired rows
    • agentRetention.ttlHours — inactivity TTL against updatedAt, 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_id is an FK with ON DELETE CASCADE, so a soft delete keeps the whole transcript readable while a hard reap takes the agent’s conversations, their conversation_events and their hitl_interactions with it. Grants minted from those approvals survive (capability_grants.hitl_id is ON 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 cancelled with a durable reason (the owner’s card finalizes as ”🗑 Agent removed”, a coalesced row’s joiners get their fan-out, a pending interception row 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_ROOT is 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 — MediaAttachment is transport, AttachmentRef {blobRef, mime?, filename?} is persistence; toAttachmentRefs converts once in AgentRun.begin() and nothing downstream sees the transport shape (its inverse fromAttachmentRefs sits beside it, for replaying a persisted turn)
    • Clean content — the DB column holds exactly what the user typed; attachments live in metadata, 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 — BlobStorageRouter routes by scheme with a content-addressed local provider; resolveLocalBlobRef canonicalizes 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 its messageId, so a failed delivery is a failed tool call

Models & Model Routing

  • ModelRef Grammar — one closed-format string names an agent’s model everywhere: the create wizard, /model, role frontmatter, models.json chains and the agents.model_ref column
  • model_update (config:write) — writes constitution/models.json from its sections (models, roleAliases, apiChains, cliChains; a section left out and any other top-level key are kept), refused as invalid when the file would not load as the runtime loads it (schema, @<effort> suffixes, case-only key collisions), and as refused when an existing model name — a role, an alias or cli:<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 is model_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 through model_widen only 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-5 is an API step (we dial the gateway ourselves), claude:vercel:anthropic/claude-opus-5 is a CLI step routed through it; the middle segment is only ever read as a router
    • AgentRef canonical form — readonly [Step, ...Step[]]; a single ref is a chain of length 1, so nothing downstream has a “with-failover” special case
    • harness ≠ provider — a CLI step carries a harness field (the subprocess wrapper), an API step carries a provider field (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 parseAgentRef throws 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
  • Catalogue and provider reads (since 1.24.0) — model_read_file (config:read) is models.json as model_update takes it (every section, the sha a commit passes as expectedSha); model_summary (config:read) says which step of each API chain (smart) and CLI chain (cli:smart) served the turns over a window (default 7d) — 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 (open while it blocks calls, with when it lets one through; half-open past 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_history pages the circuit-breaker, fallback and auth-issue events, one provider’s with provider. 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 dispatch
    • ModelEntry — id (provider:model), label, providerLabel, optional hidden, optional rateLimit.rpm, required contextWindowTokens, optional pricing (per-1M input/output/cacheRead/cacheCreation, optional cacheCreation1h — the 1-hour cache write rate, 2× input on Anthropic — or per-image), optional default reasoningEffort, optional timeoutMs (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 — parseModelsFile produces the same actionable message for the loader throw, the Models file doctor 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’s AbortSignal has 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); onExecuted records which model actually served, onFallback fires on every transition
    • CLI chains (cliTryChain) — iterate harness steps behind a circuit breaker, drop sessionId on 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.rerouted telemetry — carries fromModel, 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 to agent:auth_issue and 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 after notify.remindAfterHours); a CLI step’s notice carries a Sign in button into the in-chat sign-in below and names the dashboard-terminal login and omnislash auth-agents <harness> --force as alternatives; an API step’s (and a key-authenticated harness’s — OpenCode) remedy is its provider key, billing_failed offers none
    • A fallover reaches the user who was waiting on it — both modes ring agent:fallback from their dispatch strategy (makeFallbackEmitter), and ModelFallbackNotifier (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
  • 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 → SDK effort with minimal clamped to low, 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 and vercel:openai/… takes reasoning_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
  • LLM Provider Factory — lazy factory for AI SDK models wrapped in a ChainModel that 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) and vercel (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: and vercel: both front many vendors as provider: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/model FORM matches but the vendor slugs differ for OSS models (mistralai/ ↔ mistral/, z-ai/ ↔ zai/, qwen/ ↔ alibaba/); only anthropic/, openai/, google/ and nvidia/ are identical. A twin is two independent models.json entries — verify against GET 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 pricing block silently gets a single retry and no cost at all
    • Observability hooks — executedModelId after each settle, onFallback and onRetry wired into model.rerouted / runtime.retry stream events
  • 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; openUntil accepts an explicit provider reset time and never shortens an existing window
    • Per-model rate limits — rateLimit.rpm from models.json is enforced proactively via the shared RateLimiterPort under category llm:{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 own timeoutMs in models.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
  • 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 in MODEL_ROUTERS (openrouter, vercel) and the whole triple stored in the single model_ref column
    • 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 cliChains step whose router key is unset is dropped when the chain expands, omnislash doctor fails 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_KEY for its subprocess and a routed Codex turn replaces OPENAI_API_KEY with the router credential, so a key you set for something else can neither hijack routed traffic nor leak to the aggregator
  • 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, optional router, webSearchRequests
    • Billing type — metered (real money) vs subscription (phantom cost) with the plan recorded (max, pro, chatgpt:plus, …); the two are reported apart and never summed. A CLI login’s subscription stays subscription after its session expires — metered means 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 (an anthropic:<id> entry with pricing) 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; doctor Model prices names 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 — calculateSegmentCost bills cache reads at the cacheRead rate with a fallback to the input price, bills the reported 1h share of cache writes at cacheCreation1h (falling back to cacheCreation) — 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.context is 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.firstCall is the FIRST step’s (on a fresh session, the whole prefix as the model received it)
    • Per-harness usage adaptation — Claude reports per-step BetaUsage deduped by message id with a cache-creation TTL split and a context window; Codex is turn-total spend (cached_input_tokens pre-deducted, reasoning_output_tokens passed through) with the context snapshot read from the thread’s rollout file (its newest token_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 /stats turn 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_*, capability usage: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 plus other), 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) and usage_tools (calls per tool name — which tools a role really uses). usage_breakdown also groups by trigger (what started the run: cron, event, manual, agent, parent, chat — a turn outside any run is chat), 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_summary answers the window’s costPerSuccessUsd; usage_trend takes unit: "per_run" (its split rows per run too) and marks its spike buckets (markers); usage_summary and usage_heatmap take a filter too. The window is the one shape every period read takes — window (1h, 24h, today, yesterday, 7d, 30d, 90d, mtd, all) or wall-clock from / to (YYYY-MM-DDTHH:mm) with an optional tz, converted in the brain’s timezone (or tz) 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; null means nothing in it was priced, never $0. The eval cost_usd metric 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 the turn.completed rows it folds) leave the spend in place; written once per segment by the agent runtime’s finalize, so a replayed finalize counts nothing twice. Kept usage.retentionDays in constitution/MANIFEST.yaml (default 365, 0 = forever), reaped by the agent-retention sweep; read it with the usage_* tools (below) or db_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 a kind: 'external' ledger row attributed to the invocation (execution, scenario, requester), so the run’s cost_usd and 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)
  • 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 in locale.timezone
    • Seam — engine every limit null → MANIFEST budget.{brain,scenario,role} → a scenario’s or role’s budget: frontmatter, folded per window; null overrides the default below (resolveBudgetLimits, @omnislash-ai/config/spend-budget)
    • One gate — the agent VO’s prepare-params sums every applicable budget in one ledger query (readBudgetWindows); a refused turn ends failed, errorCategory: 'budget_refused' (BUDGET_REFUSED — recognised by the category, never the message; distinct from budget_exceeded, a CLI turn that ran past its own cli.maxBudgetUsd), execution error_class budget_refused
    • Consumers — a workflow task refusal parks the run awaiting_retry keeping completed tasks; safeRunAgent returns the failed result to pipeline code; chat, heartbeat and hook turns end failed and the next one re-asks
    • Incident card — HITL approval, producer budget, one per (scope, window, windowKey) to the default HITL chat, expiring at a day / month window’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_update refuses the budget section and the role_* writes a budget key. The dashboard binds all three
    • budget_status (runtime:read, op budget.status) — every limited scope with its spend, effective limit, raises and the card that speaks for it (pending / cancelled)
    • Loop cannot move a ceiling — budget is a measurement block: a loop proposal touching it is refused, and loop.budget (the loop’s own per-run allowance) stays a separate ceiling

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 layer
    • live (default) — the agent works directly in the live brain checkout and the brain-sync daemon commits and pushes its writes to the G-set; no git push needed
    • clone — its own git clone --shared with a pushable remote and no jail, forced regardless of isolation.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 PR
    • sandbox — the same clone plus a bubblewrap jail; persisting it requires project-level isolation.enabled (a hard write-gate), and a purely per-dispatch sandbox on an isolation-off brain degrades to clone with a warning
    • Three declaration levels — AgentSpec.runIn in *.heartbeat.md / *.hook.md frontmatter (the only durable place for row-per-fire agents), the persisted agents.run_in row default, and a per-dispatch AgentRunOptions.runIn override 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 through agent.create’s dry run), and agent_create / agent_task / agent_update / agent_call / agent_enqueue take the same vocabulary
    • Doctor check Agent runIn — reads the same oracle the create UI does: fail for sandbox declared on a brain with isolation off (a row-per-fire tick would never mint its row at all), warn for silent degradations (clone/sandbox with no baseline, dev-gated sandbox, an inert runIn on a scenario block)
    • Degrade, never throw — a forced clone/sandbox with no baseline falls back to live rather than failing the run
  • Per-Scope Sandbox Checkouts — git clone --shared from 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) and agent (standalone chat/dev, cloned per the run_in ladder), both through one resolveSandboxRepoPath
    • The owning run owns the turn’s cwd — a turn carrying the run’s auth.runId attaches 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.repo holds a repo name (two engine-internal writers still pass a path; packages/infra/CLAUDE.md scopes 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 ## Environment section of a clone/sandbox run carries a Checkout: 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 move HEAD, not origin/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 its loop creator 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
  • Claude builtins follow the grant — a Claude turn is handed only the harness built-ins its capabilities name (HARNESS_BUILTINS: read, plan and ToolSearch always; file:write → edits, engineering:execute → Bash and its background control, research:read → web, harness:subagent → Agent, the harness’s own in-turn subagents — not engine agents, which are agent_task under agent:execute), as an allowlist, confined or not — the capabilities being the role’s plus its live grants’, so an approved engineering:execute hands 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.confinement carries three repo-relative glob lists (editable / denied / unreadable) that each CLI harness compiles, with the agent’s grant (no file:write ⇒ nothing editable), into its native permission surface; never persisted
    • One glob dialect — @omnislash-ai/types/path-glob matches 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
  • Bubblewrap Jail — a per-subprocess bwrap wrapper 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_ROOT is 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 with outputArtifacts fail with Missing 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 ENOENT on a path its own system prompt named, and git status in the checkout stays clean
    • Session stores bound RW — ~/.claude (with CLAUDE_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 and live agents pass straight through the shim
    • Container prerequisites — apparmor=unconfined + seccomp=unconfined + systempaths=unconfined are all required (the third for the fresh /proc mount), and omnislash doctor probes 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
  • Brain Baseline — a dedicated brain_baseline volume holds the full brain clone that every sandbox alternates against, with exactly one writer
    • Single writer — the brain-sync daemon 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> onto refs/remotes/origin/<default> as well, because git clone --local reads local branches and a fetch-only baseline pinned every sandbox to the bootstrap commit forever
    • git gc forbidden — gc.auto=0 is 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-at in 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”
  • Git Write-Back — commits leave a sandbox over the network, never through a shared filesystem
    • Push to the real origin — the per-clone origin is rewritten to the baseline’s upstream (a local clone would otherwise point at the read-only baseline path), so an agent’s git 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_TOKEN for gh, GIT_CONFIG_* carrying http.<remote>.extraHeader for git); everything the App cannot serve (non-GitHub remotes, ssh URLs — D7) falls back to the host key. ~/.gitconfig is bound read-only into the container and the jail either way, ~/.ssh only 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_URL and CONNECTIONS_TOKEN_ENC_KEY by 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, and mirrorAuthorToCommitter copies 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 by since / until — each tagged person / evolution / brain-sync / engine (filter tags) with its squash-merge PR; paged by cursor
    • 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 like git_history; binary, large or over-budget diffs are named only
  • 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_app card 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 in omnislash.git_apps (one row per provider). The PEM never leaves the engine and the tRPC surface exposes only appId/slug/htmlUrl; the manifest’s permission set is a brain-widenable floor (git.appPermissions) and its installability is git.appPublic (default true, so one App can hold installations on several accounts) — both in packages/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 ambient GH_TOKEN) and why (ssh-remote, no-app, account-not-installed, repo-not-granted, …) — or lane unknown when 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') — register is 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 install card, or the dashboard’s Add account (git.authorize({ step: 'install' })); GET /github/install-callback writes omnislash.git_installations from an App-JWT GET /app/installations/{id} read rather than from the redirect query, because a spoofed installation_id is expected there. One App, N accounts — a second org is a second row
    • Installation-token mint — getInstallationToken signs a 10-minute App JWT with the PEM and mints an hour-long token scoped to bare repository names, cached in Redis under git: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, gh and agents — engine-side git (brain-sync’s brain-repo and attached-repo clone/pull/push, the repo-sync worker, the loop’s gh calls and revert push) and every sandboxed CLI agent resolve the credential from their own remote or checkout origin; it reaches git through GIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_* (appended after any inherited slots, so a compose-level safe.directory=* survives) and gh through GH_TOKEN. A checkout the App cannot serve yields no credential, which is itself the signal that unmasks ~/.ssh in the jail. GH_TOKEN set 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_TOKEN row), which its Credentials row restates and the doctor’s GitHub merge gate probes for Commit statuses; omnislash doctor → Git identity says which lane is live and whether pullRequest: true can work at all; → Brain git remote says the same for BRAIN_GIT_REMOTE specifically
    • 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 points hook_attributes at POST /github/webhook on 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 sealed webhook_secret: X-Hub-Signature-256 is verified as HMAC-SHA256 over the RAW body with timingSafeEqual, and no secret on record means 401 rather than a trusted payload. Each delivery is recorded in the inbound sink as a typed github:<event>.<action> event (flat payload, sender-written text fenced, resource keys github: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 (closed ends its merge wait, auto_merge_disabled turns 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: read join the floor; git.appPermissions raises 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 settles none after 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-PR isRequired, 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
  • 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 a code_repos row (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 no repos field in omnislash.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>, whose origin is 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 status in a third-party checkout is never dirtied
    • Doctor checks — every resolved repo path is validated on disk (missing = warn), and every scenario agent.repo must name an attached slug or an absolute path (unattached = fail); a DB the doctor cannot reach downgrades both to warn
  • 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 the code_repos rows; 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 --abort cannot), 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 edits commit (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-resolver against 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
  • Isolation Configuration — ProjectConfig.isolation { enabled, kind: 'bwrap' } (default off), plus BRAIN_BASELINE / RUNS_ROOT / ARTIFACTS_ROOT env paths that default to temp dirs in dev
    • Mode matrix — in prod a baseline always exists so run-scope always clones; in start it depends on a configured remote; in dev there is no baseline and every rung degrades to the live host tree
    • BRAIN_GIT_REMOTE is 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 / ~/.codex session stores and the artifacts root live in named volumes or host binds so a HITL pause survives a container restart; the jail’s /tmp is a tmpfs and deliberately does not
  • 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_updates is 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, autoRetry for 429s and 5xx, apiThrottler for rate limits, a log of every chat call slower than 5 s, scrubTokenErrors to 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 explicit OVERTAKE_PREFIXES opt-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
  • 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 / limit replaced whole); keys are numeric SubjectIds (tg:user:<id>, tg:chat:<id>), a @username is 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.role serves 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 /model tuning
    • A closed set of tiers — owner (global: env owners in TELEGRAM_ALLOWED_IDS, numeric only, plus identities: owner; every command, control buttons, HITL approvals), member (talks through engage, runs the rule’s commands), stranger (no turn; lines reach the context block only under context.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_grant refuses above it and points at access_widen; owners are never capped; a Secretary counterpart reads the bot’s default ⊕ dm ceiling
    • One gate, before the log — accessMiddleware decides before observe: blocked dropped, strangers refused (a DM gets the rule’s decline), a member’s closed command refused privately, and the turn carries the sender’s tier and capability ceiling as auth.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 tier identities gives the chat’s tg:chat:<id> (list your own journal channel as owner), else what members gives anyone unlisted — a stranger under owners / listed, a member under everyone — no admin rights needed
    • Unknown groups ask — my_chat_member records every chat a bot sits in (bot_chats, plus each bot’s getMe() health in bot_profiles); a group no rule serves follows unknownChats: ask (default — an owner card: Serve… → role → who may talk / Only me / Leave, answered in Telegram), ignore or leave. A group upgraded to a supergroup keeps its rule, its memberOf references 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 every access.yaml change; 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_explain tool 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 member of the counterpart’s routed role, outside access.yaml
  • 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, optional sendVoice / 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_send fail)
    • Container lifecycle — createChatServices() wires the whole graph with ordered start() / stop() hooks (delivery recovery first, notification worker only after card handlers are subscribed, content hot-reload in every mode including production)
  • Chat Command Surface — the /commands a 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
  • Per-chat Configuration — per-chat active agent, agent override and model override, backed by an LRU cache over a DB-persisted chat_state row 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 nova in 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 — /model shows the current ref, the models.json entries 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
  • 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-world runIn check are a pure toAgentPatch, 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
  • Chat Settings — /settings shows 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’s speech.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
  • Chat Dispatch Modes — a per-chat switch (/settings → Interrupt mode) decides what a message sent mid-turn does
    • queue (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 stale
    • interrupt — 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 /steer flushes 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_inputs state, 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 — ChatTurn is 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, close with roll-over, closeStopped, abandon on 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
  • 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 — completed delivers the answer; failed delivers it followed by the ⚠️ line and [🔄 Retry] under the usual audience rules; cancelled / interrupted deliver 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.rerouted with a next step clears the pending answer (deduped per turnId/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 presentStatus call 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 — renderAgentOutput emits 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
  • 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; null on 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
  • 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 open and at a HITL resume, 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_id over the global conversation_events id 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
  • 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’s engage (addressed · always · never), with policy.engage() over it — one function from normalized signals to a verdict, resolved once per message and cached — engagement.md
    • Signals — surface, self and roster (persona names), scope, chat (tg:chat:<id>), chatTitle, sender (tg:user:<id>, username, name), tier, the resolved rule, memberStatus(), addressed, automaticForward, senderChat, text, media / replyToMedia kinds, threadRoot identity, messageId, and onBehalf for 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’s engage), a boolean shorthand, or a decision carrying steer (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) and transcribe.autoPost
    • Per-chat knobs in the rule — the chat rule’s agent / context (thread rows, 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[].engaged are 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
  • 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_chat messages 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 chatId and 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
  • 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 (or senderChat) 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: false hides 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
  • 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 thread bootstraps 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 carries name (the persona and chat-agent slug), tokenEnv (the env-var name, never the secret), optional role (what an owner’s DM runs; the one bot that omits it is the chief’s bot and runs chief.role — two bots omitting it fail the load), policy (the chat frame’s code tier) and secretary; omitting bots synthesizes exactly today’s single-bot behaviour
    • bots: [] — no Telegram surface at all — an explicit empty roster derives services.bot false (an enabled one throws), keeps the TELEGRAM_* trio optional and drops Grafana’s telegram-ops contact point; hasTelegramSurface(config) is the one predicate consumers ask
    • botId derived from the token prefix — computable in every process with no API call, so out-of-band delivery recovers the owning bot from a stored Actor
    • deliveryKey = 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 owner channel 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 is chief.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 — the owner channel, headless voice_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’s System messages check 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
  • Secretary Mode (Telegram Business) — a secretary: true bot 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 — businessOwnerId is 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’s engage() reads as onBehalf.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
  • 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 getFile cap 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-notifications queue, 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_send and brain-tool context.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’s speech.chain whole, hot through brain-sync, written by role_update) and delivered as native voice bubbles via MessengerPort.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
  • In-Chat Introspection — /context and /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 · caps on both modes: the grant selects the CLI agent’s MCP catalog exactly as it selects the API agent’s tool set, and in prompt is what of it rides turn 1 (on API what the role’s toolLoading keeps out of tool_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 modelRef is what the agent was ASKED to run (a role, a chain, an effort suffix) and names no concrete model; /stats’ Last line and /context’ served by name 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). /context takes its window denominator from that model rather than from the ref, since the snapshot it divides was taken by the model that ran
    • /stats reports fallovers as their own line — count plus the newest transition (from → to or chain exhausted), its reason and the provider’s own error, read back from the model.rerouted rows 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 /clear while /context resets
  • 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, runIn rung 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.md personas, role-*.md files and engine constants, unioned, identity only), the dashboard’s agent picker reads role.list (offering only the roles feature.list reports on), and every agent card prints the role its row carries
  • /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 /p walks 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
  • Artifact Browser — /artifacts gives 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, the tools allowlist — HITL-gated, agent-control and checkout (shell, files) tools refused —, csp origins, device permissions, inputSchema), addressed ui://<feature>/<name>; an HTML publication opens ad hoc as artifact:<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 needs feature:write); doctor Brain entities names 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 closes superseded)
    • 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, event widget:submitted, resource widget:<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, maxMediaBytes per 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: turn a burst of submits comes back to the chat’s agent that opened the widget as one turn, under the opening turn’s ceiling; with deliver: chat a 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_get answers it fenced); pipelines get openWidget / 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’s csp, 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; media grants published files as URLs alive only while the widget is open — at open or to the open page — and voice_render / elevenlabs_sound_effect / image_generate with widget: { 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; without widget an 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; state derived under the widget’s lock, so snapshots never land out of order) and page, 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 chat lines 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 as echo lines 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 policy transcript: 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’s toolTimeoutSeconds; a failure shows the person only the tool’s own refusal(…), 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’s ui://engine/form by default), open a widget bound to the card that answers it as the person, through the one HITL gateway (an approval’s data held to its responseSchema) and closes when the card is decided anywhere; the engine’s pages ui://engine/form and ui://engine/review (item-by-item approve / reject / edit) open with widget_create too; pipelines pass widget to awaitApproval / requestApproval / requestInput, agents to hitl_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’s browser:; widget_create({ to: 'tg:user:<id>', launch: false }) hands one person a link; widget_list_sessions / widget_revoke_sessions and a block in access.yaml end browser sessions; a HITL card’s page wants a fresh sign-in; doctor Widget browser sign-in gives 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 with widget_create({ draft })), rendered in the real host (the Telegram shell with a recording Telegram.WebApp, or the dashboard frame) under its own CSP in the browser service; screenshots reach the calling model only (modelMedia), a report lists problems first (errors, CSP refusals and stopped requests with the .widget.yml key that allows them, overflow, clipping, tap targets, contrast, dark-theme misses, the inputSchema check), 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) and connectDomains (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
  • 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 doctor validates the chat surface itself, speaking with the chief’s bot (the one that carries system messages; System messages fails when there is none): Telegram privacy mode (warns when a plain-group log would be holed), and Telegram chat reachability (probes every TELEGRAM_*_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() — one ToolDef shape is the single source of truth for the LLM schema, authorization, UI grouping and routing decisions; createTool adds the Zod schema and a typed execute.

    • Required fields — name, description, schema, risk, providesCapabilities, label, category (the group key brains target in engine-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 MCP tools/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) and outputSchema (JSON Schema for result.data, surfaced as structuredContent on success)
    • replay: 'live' | 'dry-run' — what the tool does in an execution the loop launched (a pre-merge replay and everything it spawns): dry-run makes the gateway answer a success marked dryRun without calling execute. Undeclared, an engine tool is live only when annotated readOnlyHint: true (fail closed), and a brain tool is live — its body reads context.auth.loopLaunched itself
    • source attribution — auto-stamped engine / project / integration at registration
    • Per-tool rate limits — rateLimit: { perMinute?, perDay? } enforced by the distributed limiter
    • Description budget 60–300 chars — held by review (expectDescriptionBudget checks 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 optional metadata.attachments (media sent to the person’s chat) / metadata.externalContent and pauseData for HITL tools; helpers success(), failure(), classifiedFailure(code, message, classification)
    • Images for the model, not the person — a tool that declares showsModelMedia: true (and no outputSchema: over MCP a declared one would cost the images) answers modelMedia: [{ 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 as model_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 MCP image block. data still says in words what the images show. Only an agent’s call carries them: a pipeline’s callTool, 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 and key replaces an earlier one on the next step (it reads [superseded]); an unkeyed image stays for the turn
  • 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 tools and metadata-only defs; register, registerDefs, registerProjectTools (auto-stamps source: '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 its ToolSession (effective grant — the role grant resolved at dispatch, plus the agent’s extras, ∪ live grants — + interactive + memoryEnabled): agents: false and (over MCP) mcp: false excluded, requiresHuman stripped when non-interactive, memory tools refused when the role declares no memory: block (even under '*'), then the grant — '*' everything, otherwise a matching capability; the two capability-less hatches always (AskUserQuestion subject to requiresHuman, hitl_request_grant on every session). toolsFor(session, delivery) serves the API tool set, the CLI MCP catalog, /context and the pricing; ToolsGateway.executeFor refuses every agent call outside it (not_offered, naming the tool’s capabilities and pointing at hitl_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 (only AskUserQuestion / hitl_request_grant are capability-less), so no tool is reachable only under '*'
    • callable vs in prompt — isEager(tool, eager) (mcp-wire.ts) is turn-1 eagerness: the alwaysLoad engine tier plus the role’s toolLoading.eager; it never selects a tool, it only decides whether an offered one loads up front, and deferredOnTurnOne applies it per delivery, so the /context report, 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 one tool_search (the AI SDK’s provider-agnostic toolSearch(), 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): eager keeps a role’s hot tools loaded (and marks them alwaysLoad for direct Claude CLI), 0 defers everything not eager, false never 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_search is 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.ts per package — metadata-only defineTool exports for consumers that want the static set without instantiating providers
  • 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 RedisRateLimiter keyed 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 — ToolErrorData with code + classification: unknown_tool/not_offered/hitl_config/missing_auth_context/missing_grant/validation_error (permanent) and rate_limit (transient, with retryAfterMs)/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 from auth (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.loopLaunched a 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.notify validates 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 plain ToolContext with notify (channel-addressed delivery for tool bodies, awaits real delivery) and getServiceToken(providerId) (fresh OAuth bearer)
    • Per-call side channels — emitProgress(message) for MCP progress notifications and signal: AbortSignal for client cancellation, plus sessionId, repo and skillEnv
    • Live derived name-sets — hitlToolNames is a getter and the requiresHuman strip-set is computed per invocation, so a hot policy flip can’t leave a HITL tool executing without pausing the run
  • 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; bare false entries are except-mode
    • One gate, no “hidden but callable” — ToolRegistry.isEnabled covers listing and resolution, so a disabled tool is rejected by name as unknown_tool on the gateway, MCP and webhook paths
    • Project tools exempt — the brain governs its own registrations in code, so default: false can 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’s Tool policy check 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": false silently loses every category the engine adds after its policy file was written
  • 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_CAPABILITIES feed only the rejection text — a load error for engine-tools.json / toolLoading.eager, a refused grant request, unknown_tool naming 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[] under omnislash:tools:catalog at 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.
  • 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: false removes 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
  • 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 with timeoutMs + timedOut snapshot), agent_call, agent_enqueue, agent_await, agent_answer, agent_escalate; the session commands are agent.* ops — agent_send (the chat verb: the agent’s inbox, merged with its other chat messages, refs riding as the text’s <refs> block as on chat_send; agent_enqueue stays 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 }, the AgentPatch fields; a widening — a model whose harness ignores the grant, capabilities the caller does not hold, a less confined runIn — is refused, agent_widen makes it under role: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, as agent_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_compact and agent_flush_mailbox refuse an agent a live workflow run owns (an active interception exempts agent_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 the sub-agent-supervision skill
  • Engine system tools — memory — memory_search, memory_read, memory_write, memory_edit, memory_delete; the whole family is registered alwaysLoad so 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) and skill_read_resource (a whitelisted file from scripts/ / references/ / assets/, or the overridden engine version under engine/), both skill:read, safe and alwaysLoad (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 / status when 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 the artifact.* ops (§ Artifacts & Deliverables › Artifact publishing)
  • 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’s sha), access_identities (who is in a chat and the tier each one has there; with no bot, every identity — a person or a chat that speaks — the brain lists and where their tier is set; with identity, that identity across every chat and DM), access_explain (simulates one message — bot, chat (dm or a group), sender (an id or a @username) or senderChat, text, addressed, reply, a status stub — through the bot’s own resolver and the brain’s real policy code; an ordered trace setup → served → rule → tier → engage → commands → limit with decided, 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, rule on default / 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 @username resolves through the chat log, then the brain’s Telegram account). Each write is one action-log row. The chat-access engine 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 the health.overview op 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 explicit caveats list), doctor_check (engine self-diagnostics, including live LLM and embedding provider liveness read off the circuit-breaker record — the generated tool of the doctor.check op, so it returns exactly the report the dashboard’s Doctor page renders; maxAge (seconds) takes a report this process ran that recently, checkedAt saying 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 the infrastructure.endpoints op 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 (with governance:read), bots, chats and people (with access:read), ranked, one group per kind with truncated, 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 in failed — the op behind the dashboard’s Cmd/Ctrl+K palette).

  • Engine system tools — data and queues — db_query and db_execute (search_path pinned to public, so omnislash.* needs qualifying; ;-separated statements run as one transaction and every statement reports on its own, in order — {results: [{command, rowCount, rows?, truncated?}]}, rows present 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…$n through params (scalars; JSON as a string cast with $1::jsonb; with params the call is one statement, Postgres’ own rule), so no value is ever quoted into the SQL text; db_execute takes dryRun to 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 as values, in the enum’s own order; covers public and omnislash by default, one optional schema to narrow, each entry tagged with the schema it came from, each table and column carrying its own DB comment as note, and a reported truncated when the description hits the column cap), kv_get / kv_set / kv_delete (kv_get the generated tool of the kv.get op since 1.25.0, its name, input and answer unchanged), queue_get, queue_create_job (ships enabled: false), event_ingest (records an external event in the durable inbox — hooks and dispatch react asynchronously — deduplicated permanently by (source, idempotencyKey), optional resources; engine-reserved sources refused; since 1.24.0 the generated tool of the event.ingest op: 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 its source; the webhooks door keeps its own event_ingest at POST /tool/event_ingest for an external system — fresh events, the answer { eventId?, status }), brain_sync (force an immediate bidirectional brain git tick — the generated tool of the brain.sync op since 1.23.0: { outcome: 'synced' | 'skipped', durationMs, conflicts, message }, skipped when 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, so RETURNING works and a DROP VIEW; CREATE VIEW swap can never leave neither; db_query’s tells the agent to qualify omnislash.*, to bind values via params, and to read the retention notes db_schema carries before it treats engine rows as history; all three kv_* descriptions redirect agent state to tables and keep KV for pipeline cursors, dedup and last-seen markers
    • db_schema answers the three questions a column list cannot — kind (table / partitioned / view / materialized / foreign, from relkind), the definition of 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’s constraints[] (pg_get_constraintdef over PK / UNIQUE / CHECK / FK, so an invariant the schema already enforces is not re-derived in prose every turn). The whole answer is one pg_class ⋈ pg_namespace ⋈ pg_attribute read — information_schema could produce none of the three, named every enum USER-DEFINED and every array ARRAY, and did not list matviews at all — run under the same search_path = 'public' as db_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, so db_query, db_execute and 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. Not pg.types.setTypeParser, which is process-global and shared with Drizzle. Recorded trade-off: int8 / numeric → number loses 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 LOCAL takes no parameters), the name checked against a qualified namespace.name pattern 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 from auth.agentId, never from a tool argument
  • What is in the brain’s tables and key-value store (since 1.25.0) — db_overview (db:read): every table and view of public (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’s reltuples when that reads 0 — never a count(*)), 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 by created_at / createdAt / updated_at / updatedAt when 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 (jsonb compact) cut at 500 characters with …; the name is looked up in the catalog, so one that is no table or view of public is not_found and never reaches SQL as given, and an agent’s call binds its id like db_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_get reads one); at most 200, truncated. All read-only, on the read path db_query uses (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, registered mcp: false because 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 the sha a policy write swaps against; since 1.24.0 toolsKnown — false when the tool catalog could not be read, so every tools is 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 — your engine-capabilities.json changed it —, or project), the policy file a write of it lands in, every providing tool, the scenarios whose plan requires it, the entities whose grants: names it, its grants per status and open requests), capability_read_file (since 1.24.0: a policy file as written with its state and sha — 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 the at of the answer it holds as since and gets only the traces that moved, with ids — every trace of the full read — to merge them by) — the Brain Atlas read model (@omnislash-ai/atlas) as ops, and repo_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, /atlas and 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, rejected by a 4xx, blocked, skipped — nothing sent, the target was not configured when the job ran —, failed after 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: true in the body and X-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 its source (engine / brain), an event source’s today and bySource count the events its hooks processed, and http.baseUrl is 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_list rows carry each repo’s GitHub repository and visibility, sync state, attach time, the lane that authenticates it and outside (no installation serves it); repo_create / repo_delete (config:write) attach owner/name under a slug (a slug in use is refused, never re-pointed) and detach one (the checkout stays on disk); git_overview adds each installation’s status and createdAt and 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, so repo_list and git_overview share it); git_authorize (integration:execute) is the App registration / install link for a person — an agent calling it is refused toward git_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 (engine or brain), declared risk, every capability it provides and whether one waits for a person (gated), what brings it (provider: an engine platform, a brain defineIntegration bundle, the brain’s own tools), webhook and MCP exposure and whether it loads up front (loading); filtered by q, source, risk, category, exposed: "mcp", capability, provider (the tools one provider brings), with counts over the whole catalogue, the engine tools constitution/engine-tools.json switches 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 over 1h, 24h or 7d — 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 — with prometheus.state (up, unreachable, or failed — it answers but the query failed) and its retention said, every Prometheus section null unless it is up, and calls by agent from the conversation log beside it (which answers either way, with its own coverage); tool scopes 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 literal search, newest first, a page at a time; polled, not followed — ask again with from moved for what came since; precondition where no Loki runs (omnislash dev); event_tail (event:read) — the events themselves as they went over the bus, newest first, by name and since, from the capped tail of the last 20,000 (tailFrom says 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 hold event:read; config_get (config:read) — the configuration as the engine runs it, each value with its tier: every MANIFEST key (brain where the file sets it, engine where the default fills in), the data sections of omnislash.config.ts and 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_status under runtime: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 by registerWorkflowEngineTools (run_diagnosis, run_update_blueprint, pipeline_list); run_list / run_get, the eval_* tools and every loop_* 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 the run.*, eval.* and loop.* 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 --stdio transport for Claude-Desktop-style single-parent use.

    • Session lifecycle — one (Server + Transport) pair per initialize, Mcp-Session-Id on every subsequent request, DELETE to 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) via parseAuthFromUrl (pinnedRunId = the replay pin a turn on pinned content hands to every sub-agent it spawns), the run the session’s work counts toward runRef= (JSON { runKind, runId }, what a brain tool records as lineage; malformed reads as absent), the execution the session’s turn runs inside execution= (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 flags unattended=1 / loopLaunched=1 (only the literal 1 reads as set), plus execution traits via parseSessionOptionsFromUrl — interactive=false / memory=false (closed-loop: only the literal false is recognized; memory=false = the role declares no memory: block, so the memory tools are refused) and the agent’s memory lines memoryScope / memoryReadScopes / memoryWriteScopes (open-valued, re-validated against MemoryScopeSchema — the writable set keeps lines only and drops '*' or an authored corpus — unrecognized → default line; memoryWriteScopes is 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: false never exposed, agents: false never exposed, requiresHuman: true stripped and rejected in CallTool for non-interactive sessions; CallTool re-checks all three flags so a harness cannot call a name it never saw
    • Resource-exhaustion caps — MCP_MAX_BODY_BYTES (4 MiB → 413) and MCP_MAX_SESSIONS (1000 → 503)
    • Security — loopback MCP_BIND_HOST default, MCP_ALLOWED_ORIGINS allowlist enforcing 403 on browser origins (DNS-rebinding defense), SDK-delegated MCP-Protocol-Version validation, MCP_ADMIN_TOKEN-gated /admin/sessions
    • JSON-RPC error envelopes — every /mcp guard (403/400/404/405/413/503/500) answers in the spec’s error shape; /health, /ready, /metrics keep plain JSON
    • Progress and cancellation — notifications/progress driven by ToolContext.emitProgress with a monotonic counter, and notifications/cancelled wired to ToolContext.signal; opt-in per tool with a documented catch-and-translate pattern
    • MCP logging primitive — logging: {} capability plus a pino→notifications/message broadcast 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/mcp in domain mode (Caddy proxies only /mcp, refusing keyless requests at the edge): a request carrying Authorization or X-Forwarded-For must present MCP_TOKEN and is pinned to subjectId: 'mcp:default'; docker-network callers stay keyless
    • Self-serve grants through the door — hitl_request_grant from a bare-principal context routes through the mcp HITL producer, and the Telegram/dashboard approval card names the standing subject scope, 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)
  • MCP resources — read-only data by URI — omnislash://skill/{name} (eligible skills, text/markdown) and omnislash://hitl/{id} (own pending interactions only; cross-agent reads return -32002 without disclosing existence), with RFC 6570 templates for late fetch.

    • listChanged fan-out — ResourceRegistry.notifyListChanged() debounced 250 ms, fired from hitl:requested / hitl:responded and from the skills content watcher; per-session unsubscribe on server close
    • Extension point — implement ResourceProvider (prefix + list + listTemplates + read) and register it in registerSystemResources
  • Per-agent external MCP servers — an .agent.md declares mcpServers: { name: { type: 'http' | 'sse', url, headers? } } and resolveMcpServers interpolates ${VAR} / ${VAR:-default} in URL and headers, then reshapes per harness (Claude McpHttpServerConfig, Codex http_headers with SSE upgraded to HTTP, OpenCode type: 'remote').

    • Merge order — .mcp.json in cwd (Claude only) < agent-level < the engine’s own omnislash entry, 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-mcp service ships enabled by default (services.playwright), reachable at http://playwright-mcp:8931/mcp in Docker, probed by omnislash doctor, with two documented traps (pin a cli: model ref; give every ${VAR} a default)
    • Preview browser (since 1.25.0) — a browser service (Chromium headless-shell, chromedp/headless-shell) ships enabled by default (services.browser): the webhooks app renders widget previews in it over CDP, on a preview network 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 its resourceDomains); doctor Widget preview says whether a preview can render now
  • Client configuration recipes — ready-made .mcp.json ("type": "http" required), Codex config.toml and OpenCode opencode.json snippets, plus buildOmnislashMcpUrl(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, one hitl_interactions table, and one event pair (hitl:requested / hitl:responded); parallel HITL channels are banned.

    • Two orthogonal axes — kind × producer — kind owns the response Zod schema and card renderer, producer owns required context and resume/dispatch side effects. A missing (producer, kind) cell throws HitlError.unsupportedCombo at request() time
    • Six kinds — question, grant_request, approval, input_request, oauth_consent, interception; response shapes live in HITL_RESPONSE_SCHEMAS_BY_KIND and are re-validated on every respond() 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 its validate() invariants and per-kind handler cells in a closed PRODUCERS registry
    • 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. authorizeResponder runs inside respond() / 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) and HitlGatewayAdmin (respond/cancel/getById) so a container can wire one side without the other; HitlGateway structurally satisfies both
    • Injected side effects — HitlGatewayDeps extends Partial<HitlCallbacks>; createHitlCallbacks() (agents-api) plus createWorkflowHitlCallbacks() (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_ID names a dedicated HITL chat separate from the status chat, and is optional — unset falls back to the primary owner (the first id in TELEGRAM_ALLOWED_IDS)
    • Conversation binding — each row is bound to an agent_conversations row (caller-supplied conversation id reused, otherwise a placeholder minted in the same transaction); conversation_events rows (hitl_request / hitl_response) carry the full interaction snapshot so timelines render without a join
  • AskUserQuestion — one question channel across API, Claude, Codex and OpenCode — a single tool name and a single canonical tool_use sentinel guarantee “question visible to the model → run paused → exactly one hitl_interactions row”.

    • Exactly-once producer table — API/Claude-MCP/Codex-MCP/OpenCode-MCP produce the row from execute(); OpenCode’s native question tool and Claude’s bare builtin are covered by parser/queryRunner safety nets
    • Generic HITL-tool pause — ToolsGateway.hitlToolNames is derived from ToolDef.hitl === true (today AskUserQuestion + hitl_request_grant); API pauses via processApiStream’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, multiSelect toggle chips with the selection bitmask carried in callback_data (no Redis, no TTL) plus Submit, multi-question fallback to typed text; the dashboard mirrors the same { answer } contract
    • Non-interactive agents — interactive: false strips requiresHuman: true tools by the one selection rule on both paths (the API tool set; the CLI MCP session’s ?interactive=false filters tools/list and refuses CallTool), and swaps the prompt module to without-questions.md
    • Per-provider prompt nudges — with-AUQ-claude.md / -codex.md / -opencode.md / with-AUQ.md routed by SystemPromptBuilder.withQuestionPolicy
  • 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’s delegation: 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_await return awaiting_input with pendingHitl: { hitlId, kind, questions, deliverAt } (agent_get under current); the parent answers with agent_answer, escalates with agent_escalate (note shown on the card), or does nothing and the card reaches the person at deliverAt. No grant card for the parent: both tools ride agent: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
  • Detached approvals — hitl_request_approval (agent tool) and requestApproval (pipeline SDK) fire a decision card and return the hitlId immediately, so a batch producer never freezes on human latency.

    • Custom actions — payload.actions: ApprovalAction[] (1–8, id /^[a-z0-9_-]{1,32}$/, reserved ids custom/cancel/chat rejected, 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; isApprovalAction narrows
    • Three composable consumers — a *.hook.md on hitl:responded filtered by kind + tags, a later poller via hitl_list, or onReply: '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’s reply link attaches the raising feature’s reply contexts 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
    • tags lifted onto the event — copied from payload.tags to the event body so hook matchesFilter can use array-contains semantics without nested paths
    • cancellable: false — hides the ✕ Cancel button on both surfaces and rejects forged hitl:{id}:cancel callbacks; a caller holding hitl:write can still force-close via hitl_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
  • Blocking approvals and questions for pipeline steps — awaitApproval / askQuestion / requestGrant / requestInput create rows carrying context.pipelineAwakeableId and suspend on the Restate awakeable, resolved by the producer cell’s unblockOnResponse (pipeline runtime itself is the workflow slice’s).

  • A pipeline’s approval expires by default — an awaitApproval card gets a deadline when the call names none: 168 h (engine) → pipelines.approvalExpiresInHours in MANIFEST (0 = no default deadline; read live at each approval) → the call’s own expiresInMs (null = never). A waiting run keeps its journal across every deploy, so a card nobody answers must end; expired, the helper throws ApprovalCancelledError with an expired: 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 submitFresh against a blueprint declaring inputSchema opens the card and returns { kind: 'input_requested' } with no run row created; the response re-enters submitFresh via enqueueWorkflowFromInput
    • Pre-run pipeline forms — a workflow-primitive scenario with inputSchema intercepts Telegram /run and 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 blueprint inputSchema into a prompt and parses a free-text (or voice) reply back into structured fields, both derived from the ZodFieldMeta[] introspectJsonSchema gives the dashboard input drawer
    • No TTL by design — the single-responder workflow.input_request cell coalesces re-triggers indefinitely so exactly one card lives until answered or cancelled
  • Interception — take the wheel mid-run — a dashboard user opens a live chat with a workflow agent, steers it with ordinary agent.send turns, then hands it back; release and cancel both 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 — submitResume rejects every user-initiated resume with RUN_INTERCEPTED while 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_grant mid-task); an agent parked awaiting_input on a question is interceptable (the main case), and because the pause is COOPERATIVE, Release is admitted only from a resumable-or-terminal run
  • Chat supersede for pending questions — a plain (non-reply) chat message to an agent parked on a question card cancels that card and wakes the agent with the supersede reason in its resume prompt; card replies still answer the card, and grant_request cards 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 through InputRouter so voice replies work.

    • Duration rows — 4h 12h 1d 2d / 3d 1w 2w 30d under Approve/Deny: a one-tap full approve at an explicit lifetime (same customOverrides.ttlHours shape, no text round-trip). Floor is MIN_DECLARED_GRANT_TTL_MS, ceiling MAX_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
  • 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) and dispatched_at (null → ts) are independent monotonic axes; a failed dispatch bumps dispatch_attempts + last_dispatch_error and stays in the undispatched pool
    • Apply/unblock split — a HandlerCell’s fallible applyResponse (today only grants.issue) runs in try/catch and the mandatory unblockOnResponse always frees the waiter, degrading per producer (GRANT_ISSUE_FAILED_PROMPT / grants_issue_failed / resolve-anyway)
    • Reconciliation loop — hitl-reconcile BullMQ job scheduler (60 s, scheduler-produced, concurrency=1 worker) releases delegated questions whose parent head start is over (releaseDue), then drains terminal-undispatched rows older than 30 s, backed by the partial index hitl_undispatched_idx
    • Opt-in TTL + sweep — expiresAt on request(); cancelExpired() routes overdue rows through the normal cancel() 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 resumeAwakeableId triggers HitlError.barrierNotReady, which is a defer (no markDispatched, no attempt bump, no caller-visible failure) re-driven by the agent:input_request lifecycle event
    • Eager validation — unknown producer, per-producer context invariants, unsupported (producer, kind), and unregistered grant_request capabilities all fail before any DB write
    • Boot backfill for card delivery — HitlNotifier.start() re-drives pending rows with dispatch_card IS NULL (a question still held for its parent excluded — nobody was asked yet) through the same render path, closing the hitl:requested pub/sub drop window across a bot restart
    • Guarded task-run CAS on resume — enqueueAndResume flips the owning task_run awaiting_input → pending only 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 by grants.hitl_id
  • Cell-level request coalescing — a HandlerCell opting into dedup joins concurrent same-key requests onto one pending row via the partial unique index hitl_dedup_active_idx, so N producers show a person one card.

    • Grant fan-out — stableGrantKey = [subjectId, scopeKind, scopeId, sortedCaps] (producer deliberately absent) with a shared grantRequestCell that iterates interaction.subscribers and wakes each by its own producer
    • Single-responder input — stableInputRequestKey = [subjectId, scenarioId], arity-disjoint from the grant key; its subscribers array is intentionally unread so one card enqueues exactly one workflow
    • Per-subscriber wake idempotency — every wake job’s dedup key (BullMQ jobId, Restate idempotencyKey) must include the subscriber id; first-writer wins on expiresAt
  • 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’s engine-capabilities.json; an approval moderate, the rest safe), allows (the verdicts it takes now: approve · deny · answer · edit · reply · cancel), bulkable and expiry ({ 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 by status, 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 (default all; it holds the requests decided in it and never hides a pending one; window or wall-clock from / to + tz, answered back as the resolved window); sort: oldest | newest | expiry | risk | decided (default oldest for status: ['pending'], else newest; decided — newest decided first, closed requests only); pages by cursor (a cursor of another sort, or of risk across a capability-policy edit, is refused), limit ≤ 200, detail: 'compact' | 'full' | 'count' with kind-aware summaries — full adds the payload, the response and what each request belongs to (run, plan, agent, task, grant) — and facets: 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 is stuck, a grant’s grantTerms (the scope approving mints and its life when none is set) and, once closed, its outcome (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) or customReply / approvedCapabilities+customOverrides / input); a sign-in or an interception is not answered here. A request no longer pending is refused, never answered twice. Approving a grant_request takes a person: the gateway refuses every non-person [unauthorized_responder] — the active boss run inside its approvableCaps aside (never its own card, never a principal-wide subject grant) — and the card stays pending; denying (approvedCapabilities: []) is open to any responder
    • hitl_respond_bulk — hitl:write; up to 50 { hitlId, response } items, each answered as its own hitl_respond (one failure does not stop the rest) → { answered, skipped, failed }; a pending request that is not bulkable is skipped and named in skipped with why — an admission (answered in its chat), a question, an input, a sign-in, an approval with no one-tap pick
    • hitl_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 check hitl_respond already ran
    • agent_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
  • Boss agent — a meta-agent that works the HITL queue — a person starts a time-boxed boss run whose BossRunConfig fixes every security knob: expireAt (must be future), hitl.forwardingDelayMs head-start for the human, hitl.allowedKinds, initialCaps, and approvableCaps.

    • 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 MANIFEST boss: defaults (boss:read), start on a full BossRunConfig (dryRun checks it) and stop (boss:execute — restricted, HITL, granted to no role: an agent starting Boss mode asks a person first)
    • BossHitlBridge — mirrors hitl:requested into 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, initialCaps and approvableCaps into the turn body, so the boss reads its own authority instead of discovering it from a failed hitl_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.md overrides the engine’s copy whole, modelRef included (nothing is pinned by boss_start); brain-facing spec in docs/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 approvedCapabilities entry must be in the run’s approvableCaps allowlist; the list is the security boundary, the role prompt is only documentation
    • Expiry + run summary — bossExpireScheduler / bossExpireWorker end the run (user_stopped / expired / crashed) and emit boss:run.expired with a BossRunSummary (duration, turns, tools by count, HITL by kind and status)
  • HITL events on the per-agent output stream — hitl_request / hitl_response envelopes carrying the full row snapshot are written to agent:{agentId}:output alongside the EventBus emit, so live agent views upsert the card in place by hitlId instead 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 — AgentStreamRebinder re-binds the chat presenter on hitl:responded for (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 — TestHitlChannel subscribes to both events and exposes waitForRequest(predicate) / respond(), backing 21 *.integration.ts suites 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:capability string carrying { risk, hitl, description }, declared by tools (providesCapabilities) and by plan tasks (TaskSpec.requiredCapabilities).

    • Three layers, merged at runtime — hardcoded ENGINE_CAPABILITIES, brain overrides in constitution/engine-capabilities.json (risk/hitl/description only), and brain-new capabilities in constitution/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 the hitl flag
    • hitl: true gate — engine defaults are email: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 them
    • ADMIN_ONLY_CAPABILITIES — an engine-owned closed set (today loop:auto_accept) that GrantService.issue rejects from every source but admin/admin_extend and that prompt builders strip from the planner’s vocabulary; deliberately not a CapabilityMeta field so a copied-forward brain file cannot silently un-restrict it
    • Admissibility rule for notification:send — a send-class capability may be hitl: false only 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 is messaging:send territory
    • 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_policy answer consequences on the dry run and the commit alike (since 1.24.0): each changed field before → after with its direction, and widening: true when 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 (conflict when it moved), a file that would not load (a renamed key included) is invalid; the writing process reloads its catalogue, the others pick the change up through their content watchers, brain-sync commits it. An invalid file is refused for a value edit, never rewritten — same rule as regen; only a whole-file capability_write_file replaces it. One audit row per write
    • omnislash regen sync — ensureEngineCapabilities (a regen materializer, so init / regen / upgrade all 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 reported converged: false (never silently overwritten — see packages/capabilities/CLAUDE.md). omnislash doctor reports the state read-only and writes nothing
  • Grant model — one grant, exactly one durable scope — omnislash.capability_grants rows carry NOT NULL (scope_kind, scope_id), a text[] of capabilities, optional max_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); heartbeat and hook exist so one approve survives a row-per-tick / row-per-fire agent; role covers every turn run as that role and is never minted from a request — only grant_create issues 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 and buildGrantVisibilityWhere ORs them into index lookups; an empty set compiles to false (deny everything)
    • grantTargetOf mint precedence — heartbeat > scenario > hook > agent, never role, never subject, so a runtime request can never escalate itself to principal-wide authority
    • Per-kind default TTL — 24 h for scenario/agent, 30 days for heartbeat/hook/role/subject, clamped into [30 s, 365 d], and into [30 s, 720 h] for an agent’s grant_create / grant_extend (source command); a card’s customOverrides.ttlHours overrides either way
    • Lifecycle — active → revoked | expired | exhausted; consume increments atomically under WHERE used_calls < max_calls, an expired row stays expired (status is derived, never swept), revoke keeps 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:created on every successful insert (HITL approve included, carrying source) and grant:revoked
  • GrantService — one read primitive, one write path — authorize / computeCoverage answer coverage, issue is the only insert, and consume / revoke / listActive cover the rest.

    • requiresApproval filtered 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 approve fs:read
    • IssueSource discriminates replay safety — hitl_approval requires hitlId and writes INSERT … ON CONFLICT (hitl_id) DO NOTHING + SELECT; admin / declaration / bootstrap are plain inserts; admin_extend revokes and inserts in one transaction
    • Boundary validation — issue re-parses the scope through GrantScopeSchema and every capability through parseCapabilityString, because both cross tRPC input and HITL JSONB before landing
  • provisionGrants — read-then-ask orchestrator — one primitive replacing per-producer coverage+request boilerplate: probe computeCoverage, return 'covered', else open a grant_request and return { status: 'pending', hitlId }; coalescing is the gateway’s job, and it never mutates run state.

  • issueDeclaredGrants — pre-authorize an automation with a grants: block — a *.hook.md / *.heartbeat.md / *.scenario.md declares capabilities + optional ttlSeconds / maxCalls in 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 calls requestGrant is still covered)
    • Same scope, capped lifetime — scope from grantTargetOf(auth); ttlSeconds bounded to [MIN_DECLARED_GRANT_TTL_MS = 4 h, MAX_GRANT_TTL_HOURS = 720 h] at file load, default DECLARED_GRANT_TTL_MS = 24 h as a refresh cadence
    • Classification before write — classifyDeclaredCapabilities drops unknown, ungated and admin-only names per entry so their grantable siblings still get a row; never throws — a bad entry degrades to missing_grant at 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 — maxCalls on a heartbeat/scenario scope 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:created carries source: 'declaration', and omnislash doctor → Declared grants inventories every block and flags admin-only / no-approval-needed capabilities and cross-entry duplicates (an unknown name is Capability 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
  • Two-level authorization through one primitive — plan-level (checkGrants in the workflow-run handler) and tool-level (ToolsGateway) both call grants.authorize, so the two layers cannot disagree on visibility, exhaustion or the slack window.

    • Plan level — union of requiredCapabilities across task specs, missing → provisionGrants → run parked in awaiting_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-empty grantScopesOf(auth) (else missing_auth_context) → authorize → on success consume(grantIds), on failure a structured missing_grant payload
    • 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_grant for a standing subject grant, none of those → ask an administrator
    • Runtime escape hatch — hitl_request_grant is offered on every session and alwaysLoad: true puts its schema on turn 1; it asks for a gated capability after missing_grant and 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 tool engine-tools.json switches 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 standing subject grant 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 consuming maxCalls
    • 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, minus memory:* 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 /context as Requestable capabilities
    • Partial approval semantics — the agent producer degrades gracefully (issue the approved subset + partial prompt), schedule_* issues with nothing to resume, pipeline resolves the awakeable with the subset, workflow rehab fails the run
  • Grant producers, catalogued — schedule_manual, schedule_cron, workflow rehab, agent runtime escape and pipeline reach a grant through a human approve; declaration, bootstrap (boss start) and admin / admin_extend (a person on the dashboard) bypass HITL entirely; command (since 1.24.0) is grant_create / grant_extend called by anyone but the dashboard — an agent’s call waits on the governance:write card. Only admin / admin_extend can issue an admin-only capability; command never issues governance:write (the gate on its own card); a Boss run’s own grant is refused to grant_extend / grant_revoke; a subject scope only those and a HITL approve whose card names it.

  • Grant administration — the grant.* engine ops (tools grant_*; reads governance:read, writes governance:write — restricted, HITL, granted to no role by default). grant_list (since 1.23.0) reads the standing grants: filtered by computed status active|expired|revoked|exhausted, by one entity, by feature (the grants of the entities it ships), by one session (agentId) and (since 1.24.0) by capability and scopeKind, sorted newest / 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: counts per status, byScopeKind per holder kind and expiringSoon (active, lapsing within the hour). Since 1.24.0: grant_get is 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) and replacedBy once it was extended; grant_list_declarations lists every grants: entry of a scenario, heartbeat or hook (inline hooks included) with the row it minted; grant_create issues a grant for any holder (dryRun answers the row and what it widens — the capabilities that wait for approval today, any admin-only one), grant_extend replaces a live, lapsed or used-up grant atomically with more time or calls (a revoked one stays ended), grant_revoke ends one (id) or up to 50 (ids), the record kept. An agent (source command) can never grant an admin-only capability or a person’s whole principal and gives at most 720 h; the dashboard (source admin) can. Each write is one action-log row against its holder or its grant.

  • Standing delegation without an agent-visible ask — loop:auto_accept is a capability no tool provides: the loop reads it with grants.authorize and, 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 / wrapWebContent fence untrusted text (emails, web pages, webhook bodies, tool results marked externalContent) with cryptographic boundary markers, strip invisible characters, fold fullwidth/homoglyph angle brackets that could spoof a marker, and log detectSuspiciousPatterns hits 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.ts never imports env; secrets and private values are read through env.* inside packages/apps, so the config file is public committed brain code.

    • Per-skill env is filtered — collectSkillEnvVars gathers declared requires.env values for the child process, and isDangerousEnvVarName blocks PATH / LD_PRELOAD / NODE_OPTIONS / HOME and friends into a reported blocked[]
    • 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*/AIza keys and JWTs, redacts every whitespace-free process.env value 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, and artifact_publish refuses private publishing unless ADMIN_USER + ADMIN_PASS_HASH are configured
  • Agent DB access with schema isolation — db_schema / db_query / db_execute let agents work with the brain database while PG-level search_path=public keeps engine tables (omnislash.*) invisible without explicit qualification. Introspection is the deliberate exception: db_schema reads the catalog across public + omnislash and 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_schema carries each relation’s own DB comment through as note. omnislash.agents uses 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 (so services.pipelines: false stops it) and removes rows in TWO phases: hb:% / hook:% rows are hard-deleted outright once idle past agentRetention.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 say deleted_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 is packages/infra/src/queries/agents.ts (softDeleteIdleTransientAgents, findExpiredAgentIds, hardDeleteAgentsByIds) — drizzle-kit models no COMMENT ON, so the two move together by hand.
  • Chat access is governed like any other write — who may use a bot is constitution/access.yaml, edited by agents through access_update (access:write, no approval: it refuses any change that widens access) and access_widen (access:widen, hitl: true: admitting people, serving or opening a chat, granting members commands or capabilities — the owner approves the exact edits), read through access_overview / access_identities / access_explain (access:read); owner is 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, whose infrastructure:discover capability is restricted + 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 PlanningState and rewrites blueprint-state.json for the next stage
  • Stage inventory
    • 1 — Analysis — extracts slug, goal, successCriteria, assumptions, ambiguities, priority, researchNeeded and an optional inputSchema; an optional stateOverview is appended to scenario.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, enumerations and keyFacts, run with elevated capabilities (agentCapabilities: ['*']); included always / never / runtime-skipped by PlanningOptions.research, with a stub written on skip so downstream inputFiles always find a consistent shape
    • 3 — Deliverable — decides description, filename, format and schema; scenario config can pin format as a HARD CONSTRAINT, and storage is 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:capability strings plus a rationale per task
    • 7 — Task Refinement — adds definitionOfDone and the optional flag per task
    • 8 — Context Group Assignment — assigns contextGroup (or independence) and compactAfter per 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 outputArtifacts filenames 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, compactAfter and outputArtifacts — no task creation, deletion or other field edits
  • Progressive task enrichment — tasks start as {id, title, description, recommendedSkills?} from stage 4 and accumulate capabilities → definition of done → context groups → artifacts → final corrections, with assertTaskCoverage() guarding every id-keyed enrichment stage
  • Two-phase execution for interactive stages — an interacting turn runs with no outputSchema so the model can call AskUserQuestion without 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 fires onRecoveryAttempt, 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 RetryablePlannerError sets retryPending and reports; retryLastStage() re-runs from the failed stageIndex with all prior stage files intact
  • Session persistence — PlanningSession owns 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 via PlanningSession.fromSnapshot(); the snapshot shape is Zod-validated on load
  • Configurable pipeline shape — clarification, decompose, review and research flags select which stages are built, changing pipeline length and the number of agents created
  • Agent groups — stages sharing an agentGroup share one agent (capability mapping + task refinement share enrich); all other stages get a fresh agent with role system:planner and 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.modelRef overrides the planner agent model for a single planning session from chat, without changing defaults
  • File-based stage IO — each run gets a temp /tmp/planner-XXXXX/ workdir holding scenario.md, blueprint-state.json, the numbered per-stage outputs and assembled-blueprint.json; preserved across retries, cleaned up on completion, with opportunistic sweeping of planner-* dirs older than 2 hours
  • Blueprint assembly & validation — assembleBlueprint() hydrates PlanningState into a full Blueprint (injecting storage, execution and loop config), then validateBlueprint() runs Zod plus business rules (unique task-N ids, 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) plus isValidPlannerMethod() / listPlannerMethods() back the scenario method picker; PLANNER_EXECUTION_MODE splits legacy in-process methods from Restate-durable ones so a new durable planner auto-routes
  • Planning cancellation — an AbortSignal per session threaded through runAllStages (checked at stage boundaries) and into the in-flight agent.call, driven cross-process by planner:cancel.requested → planController.cancel(sessionId); the planning queue runs attempts: 1 so BullMQ cannot resurrect a cancelled session
  • Live planning cards — PlanningChannel reduces planner:session.started / stage.started / stage.completed / blueprint.ready / session.failed / session.cancelled into a Redis planning: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. The planning.* 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 by id whichever it is), planning.cancel({ id }) (planning:write, recorded against the planning session — asks the planning worker through planner:cancel.requested; an ended session is refused) and planning.list_methods (planning:read — the methods a scenario’s planner.method names)
  • 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 ScenarioExecutionOverrides payload, validated (parseAgentRef for 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 service planning-run in apps/pipelines; it produces the same Blueprint contract as the legacy planners, so downstream execution is unchanged
  • Selection — set method: htn in the scenario’s planning options; the planning worker routes Restate-planner methods to triggerPipeline('planning-run', …) while cascade / one-shot stay on the in-process controller
  • Node model — five concepts, nothing more
    • Typed nodes — every node is compound (must be decomposed) or primitive (a directly-executable BlueprintTask)
    • Method with rationale — a compound node records the rationale of the decomposition it chose plus the excluded rationales 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 reopen re-decomposes exactly one parent’s subtree with the failed method excluded; siblings and ancestors are untouched
  • Two LLM steps only — a Frame step (slug, goal, assumptions, questionsForHuman, successCriteria, deliverable, optional inputSchema, 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 sequential node concatenates its children’s phases, a parallel node merges them by index
  • Bounded by construction — maxDepth and maxNodes force a node primitive at the bound and a per-node reopen cap stops a pathological decomposer, so the loop always terminates
  • Durable, journaled decomposition — every LLM call is its own runAgent durable 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 askQuestion and resume when the user answers
    • Explicit routing — engine built-ins are served raw with no handler ALS, so the handler reads surface / subjectId off 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 runFrame re-runs, so the resolved ambiguity actually reshapes the plan rather than merely unblocking it
    • Bounded multi-round — MAX_CLARIFICATION_ROUNDS caps the loop; past the cap planning proceeds with whatever frame survived
    • TTL-bounded card — a journaled expiresAt (CLARIFICATION_TTL_MS, computed once inside ctx.run for replay determinism) lets the reconcile worker sweep an unanswered card to terminal failed with no person present
  • Real cancellation — the method-agnostic planner:cancel.requested event is honored on both paths; for htn the worker resolves the live invocation from sys_invocation by idempotency-key = planSessionId and issues a graceful admin cancel, the handler catches CancelledError and emits planner:session.cancelled, and cancelPendingClarification sweeps 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/.completed per decomposition node, planner:blueprint.ready, planner:session.failed and planner:session.cancelled itself, each inside ctx.run so a replay cannot re-fire and corrupt the order-sensitive PlanningChannel snapshot
  • Structured-output wire encoding — the decompose schema (HtnDecomposeSchema) is a flat root z.object() with a kind enum discriminator and .nullable() branch fields, because a z.discriminatedUnion renders as a root-level oneOf that both Claude and Codex reject; parseDecomposeResult validates 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.md and decompose.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-run Restate Virtual Workflow keyed per attempt (${runId}::a${n}), the single durable owner of the run
    • Sole writer — the EXCLUSIVE run handler is the only writer of workflow_runs + workflow_task_runs; the agent VO stays sole writer of agents and the run only READS agents.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.run step
    • live_attempt claim 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 — releaseLiveAttempt clears the claim at the single runBody() 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-named stealFrom recover
    • Terminal-CAS clamp — the only remaining CAS on normal writes: transitionRunStatus(ACTIVE_EXECUTION_STATUSES, derived) stops a lagging projection resurrecting a clamped-terminal run
  • workflowRunClient — typed ingress API — every driver (worker, dashboard, mcp, chat, scheduler, pipelines) reaches the handler through one client keyed on the stable runId
    • submitFresh(scenarioId, createdBy, runConfig?, opts?) — mints runId + slug and submits attempt a1; opts.delay schedules via a Restate timer, opts.idempotencyKey dedups the run row via a partial-unique index
    • submitAdhoc(...) — 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 session
    • submitResume(runId) — new attempt from any RESUMABLE_EXECUTION_STATUSES, re-driving off DB-projected progress (skips completed phases, re-attaches live agents); the single choke point where a pending interception HITL row rejects with RUN_INTERCEPTED
    • submitRetry(runId) — new attempt from failed; the handler’s failed → running CAS is the single-winner guard, so concurrent retries run the agent exactly once
    • requestStop(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, null when none is live
    • resumeForRecovery(runId, stealFrom?) — deliberately ungated re-drive used only by the deterministic orphan reconciler
    • enqueueWorkflow* 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_id null, the executions row 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 /plan still writes a features/adhoc/ scenario and shows the schedule card, and moves to briefs in the next release
  • Phase execution model — tasks are grouped by phase number; phases run sequentially, tasks inside a phase run in parallel
    • Context groups — a named contextGroup runs its tasks sequentially on one chained shared agent; contextGroup: null gives each task its own agent in parallel. Distinct groups and ungrouped tasks all race concurrently
    • Cross-phase agent chaining — the agentMap is 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: true failures don’t fail the phase or the run; the group continues with a fresh agent (the chain breaks at that point)
    • currentPhase persistence — advanced on phase completion; a resume skips phases below it
  • 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) > cancel clamp (outranks a failed task) > a required task failed > pause clamp (never masks a failure) > awaiting_approval > awaiting_input > running > pending
    • Journaled lifecycle marker — run / pause / cancel lives 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 to paused
    • awaiting_retry is explicit — set by the handler at the phase aggregate through an ACTIVE-guarded CAS, not derived
    • PR-finalize hold — a pullRequest: true run is held off completed while its finalize window is open; the hold is required and threaded to every projection, and intent-aware (a pause mid-push projects paused, never a terminal that would drop the PR)
  • 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 control promise per attempt, raced inside the phase loop
    • pause — sets the lifecycle clamp and returns paused with task rows untouched (running stays running); resume re-attaches live agents off agentId
    • interrupt (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 into completed
    • cancel — lifecycle clamp + stopRunAgents + applyWorkflowCancel terminal-CAS + pending-HITL cancel, emitting workflow:run.cancelled exactly once
    • a0 clamp attempt — cancelling a non-live (paused / awaiting) run submits a reserved kind:'cancel' attempt on key a0 that runs the terminal cascade directly; concurrent cancels dedupe onto it
    • Agent teardown, two layers — stopRunAgents invocation-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, no agentId → dispatch, bound agentId → 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 on awaiting_input, so a HITL pause arrives event-driven
    • One deterministic race — observePhase runs a single RestatePromise.race per loop turn over each advancing group’s awakeable, a shared ctx.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
    • adoptTask re-attach — a HITL wake CASes the task awaiting_input → pending keeping its agentId; adoption marks it running (no task.started re-emit) so the run column doesn’t lie and downstream from-guarded CASes still match
  • Post-completion pipeline & task auto-recovery — settling a completed agent runs save-task-result → artifact validation (DEFAULT_CHECKS) → complete, or one dispatchRecovery re-prompt (emitting workflow:task.retried), or fail
  • Failure resilience — categorized errors with bounded retries at every layer
    • RetryableError → awaiting_retry — a retryable agent failure exits the phase carrying retryAfterMs + category; a fresh attempt resets non-completed tasks and re-drives
    • Bounded setup — pre-mark-running setup steps (prepare-read / read-slug / mkdir-artifacts / setup-sandbox) are bounded by SETUP_RETRY and settle the run failed on exhaustion instead of being silently killed
    • Per-task timeout — executionConfig.taskTimeoutMs is 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 finalizeRunFailure seam 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 idempotent cleanup-run-sandbox chokepoint 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: true the 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 same enqueue+awakeable bridge as a task agent, so replay reattaches one slot and a cancel reaches it by name
    • Honest-failure contract — prFinalizerOutputSchema (Zod-derived, flat outcome discriminator) returns {prUrl, prSummary} or failed + reason; the engine validates the URL and fails the run rather than completing with a null prUrl
    • 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_DIRECTIVE is 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 the gh token there
  • 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_request response re-enters submitFresh with collected input, schedule-side approval re-runs collectInputOrEnqueue
    • Cron — schedule: frontmatter on the scenario file, reconciled into cron-{scenarioId} BullMQ repeatables; workflowTriggerWorker bridges to submitFresh with 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 + optional input + optional idempotencyKey (pipeline only: service, default the scenario id — its primary — or an auxiliary <id>-<suffix> service one of the scenario’s own *.pipeline.ts registers, any other refused invalid; handler, default run; and key, default the primary’s pipeline.targetKey; the input schema and the input request are the primary run handler’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’s inputSchema, for its run handler), started without input, opens the input request instead of running and answers inputRequested: { 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 with existing: true. Fire-and-forget: it answers run: { id, kind, existing } and does not wait (a pipeline that waits on the run uses runWorkflow); a pipeline’s id is 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 (the run.* ops, one guard set with the tools and the Telegram card) and scenario.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: true plans 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 own auto-plan-<id>-<hash> job so a manual re-plan coalesces with the automatic pass, and answers its planningId. Planning ignores enabled; 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 owned files — definition, .blueprint.yml, *.pipeline.ts —, the plan’s state, the inline hooks; a file that does not load answers scenario: 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 — never budget / evaluation / inline hooks), scenario_write_file (the definition or an owned file whole: a .blueprint.yml validated as a run reads it, a *.pipeline.ts size-checked, taking effect on the next pipeline build), scenario_widen (feature:widen, a person: the update / write that adds to the scenario’s grants:), 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 of schedule / enabled queues 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_get answers pipeline for a pipeline scenario: its Restate primitive, the key a keyless start uses (targetKey, the scenario’s pipeline.targetKey) and each handler with the key it needs and the input it takes (the run handler’s falling back to the scenario’s inputSchema) — what scenario_start’s handler, key and input name; null when the module does not load. Every entity get (scenario_get, heartbeat_get, hook_get) also answers the id it 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, with run, 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, invalid with each issue at its field), blueprint_set_lock, blueprint_delete (the plan file only)
    • Blueprint freshness — the planner stamps scenarioHash into every plan it writes, and resolveBlueprintFreshness turns that into current / stale / unknown (no stamp, or no scenario file). Surfaced by blueprint_get, which is what makes the plan→poll→execute loop terminate: after an edit the OLD plan is still on disk and answers instantly, so stale is 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 a producer='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 targetTimestamp and the response side re-derives its submit delay
  • Blueprint-level authorization — the handler’s grants gate (checkGrants → provisionGrants) blocks the run to awaiting_approval before any sandbox or agent work, using the same grants.authorize primitive 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_input and 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:remediation agent 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_input for the whole dialogue; only the agent’s run_retry call moves it
    • Agent toolkit — run_diagnosis (full root-cause report), AskUserQuestion, run_update_blueprint, run_retry
    • run_update_blueprint semantics — shallow top-level replace over goal / assumptions / successCriteria / phases / deliverable / executionConfig, with a hard invariant that every existing taskSlug must still resolve (task ids are immutable once a row exists; the tool rejects listing the orphans)
    • run_retry covers the whole recoverable triple — enqueueWorkflowRedrive dispatches 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 bumps recoveryCount; 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-world reason and the agent’s own final report (the only delivery path its diagnosis has); RecoveryNotifier posts only the failure
    • Triggers — automatic on workflow:run.failed; manually through run_recover (the dashboard’s Recover binds the same op) and the Telegram ”🛠 Recover” button — a workflow-recovery job 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; running runs must be paused first
  • 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/pending whose Restate invocation is GONE (30-min indexing grace) and PARKED rows with no waker left (awaiting_input/awaiting_approval with no pending HITL row, plus awaiting_retry once its retry_at has passed — a run a money budget parked waits for the next local midnight — 2-min grace). paused is excluded: that suspension is the owner’s to end
    • Status-filtered liveness — sys_invocation retains a terminal attempt as completed for ~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 > 0 is always logged at info. 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, and omnislash workflow reconcile (dry-run by default, --apply, --json)
  • 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-side workflow:task.progress. Every workflow:run.* event carries the run’s scenarioId, so a hook can filter one scenario’s runs (filter: { scenarioId: '<id>' })
    • Ordering contract — an event fires after the ctx.run that produced the state it describes, so “if I see run.paused, the row says paused” is safe to rely on
    • Journaled terminal finalization — emitTerminal runs finalize-summary → finalize-deliverable → terminal emit → enqueue-eval as ordered ctx.run steps 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
  • Live run status card — a burst of task/run beats coalesces into one light findCardRunView(runId) read and renders the run handler’s projected row.status directly, 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 by runId
    • Registry lifecycle — registered at attempt start outside ctx.run so 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 carrying runId; frontmatter fields mirror global hooks plus a required id: and an inline promptTemplate (agent defaults to general)
  • File-first blueprints — .blueprint.yml written next to the scenario is the single source of truth; no DB blueprint table
    • Frozen snapshot per run — blueprintSnapshot is captured at run creation and never re-read; task specs are derived in-memory from it by taskSlug via buildTaskSpecs / deriveTaskSpecFromSnapshot
    • Auto-plan reconciliation — BlueprintReconciler watches MANIFEST.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 — deriveRunSlug gives <blueprint-slug>#<runId[0:8]>, unique by construction (no count(*), no TOCTOU) and stable forever, so artifacts/runs/<slug> and the wf/<slug> git branch stay valid
    • LLM-generated run title — a nullable human-readable title column alongside the slug
  • Cost & usage analytics — per-task usage JSONB carries per-model token/cost segments; run-level costUsd is aggregated by summing segment costs across the run’s task agents, and rolls up per model / scenario / agent
  • Tool metrics & run report — buildToolArtifacts backward-scans the agent output Redis stream, pairs item.started/item.completed by itemId and flags side-effect tools; RunReportBuilder.buildRunReport derives per-task and total tool counts into a RunToolReport
  • Result summary — buildResultSummary produces a ~1000-char emoji-prefixed per-task summary (side-effect tool results > task result snippet > error) written at terminal by the run’s own finalize-summary step
  • Run control tools — run_cancel, run_pause, run_resume, run_retry, run_recover (run:execute) and run_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) or invalid (a verb the kind does not have: kill on a workflow, retry / recover on a pipeline). run_recover queues 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_get and run_summary (run:read) read every kind of run in one vocabulary — workflow and pipeline runs, heartbeat ticks, hook fires, evals and cycle steps — from the executions record: status (queued … cancelled, with needs_you / paused reasons), trigger, entity, parent and the run it re-runs. run_list filters by entity, feature (the entities it ships today), kind, status and a window (1h, 24h, today, yesterday, 7d, 30d, 90d, mtd, all, or wall-clock from / to with an optional tz — default 7d; the answer says the window it read) and finds a run by q (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, or sort oldest 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 as error: { class, head } (was errorClass): 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:head is the cause’s fingerprint; written by every failure, filled for older failures on the worker’s first boot after the upgrade); startedBy lists the runs an agent started, fingerprint the failures of one cause, and group answers groups instead of rows — per entity (each with its run_summary figures — since 1.24.0 those groups carry live, spendUsd, evalPassRate and p50Duration — 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_get takes 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 — and brainSha — the brain commit the run started on (a replay: its pinned commit), null when unknown; since 1.25.0 feature — the feature shipping its entity today (engine for a system: role), null for a run of no entity or of one no feature ships any more. run_summary takes the same scope and window (since 1.25.0 also status: only runs in those statuses — the runs still going with window: "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 the executions record existed (an engine before 1.20) are absent from run_list; run_get still 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 of bucketMs, aligned on the window’s zone), its clock (cadence), its Late / Down now (marks, judged as health_entity judges them from expected_at) and its next fires up to ahead (24h / 48h / 7d; at most 300 — forecastFires is 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 within ahead and the engine passes on a clock. feature narrows it to one feature, entity to one lane; honesty says how much of the window is on record
  • Run trace, compare and history (since 1.24.0) — run_trace, run_compare and run_history (run:read) read inside one run of any kind. run_trace answers 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), and honesty: 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_compare aligns a run with another — by default the previous finished run of its entity and kind (against names any other) — by label path, with the deltas (duration, cost, tokens, status), the input diff leaf by leaf and the two brain commits. run_history is 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 holding hitl: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 edited input — as a new run linked by rerun_of (run_get lists it under reruns); the input is checked against the schema the entity takes now, which run_get answers as inputSchema; dryRun: true answers 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 to run_rerun or run_purge is refused invalid (it starts again with heartbeat_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 — and run_read_file reads one by its ref (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’s rerun_of names 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 the run_kill + scenario_start under a new key route in the refusal. The same reset is what the engine applies to a run a code change breaks under onCodeChange: 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.md only), 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 with execution.engine in frontmatter
  • All three Restate primitives — restate.service (stateless), restate.workflow (bounded signalable run with ctx.promise + shared handlers) and restate.object (long-lived single-writer entity) are accepted as the module’s default export; 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
  • Auto-discovery & registration — one loader (loadServedPipelineModules) takes the enabled pipeline scenarios, resolves {id}.pipeline.ts plus 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 and force: true on reload, non-blocking so the service starts first
    • omnislash doctor --check pipelines (CLI Pipeline 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 gates
    • Pipeline 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), in doctor_check and on the dashboard; pipeline_list points an agent at it
    • pipeline_build_state (pipeline:read, the pipeline.build_state op) — the same record as data: serving / degraded / down, what serves and which modules did not load since when; unknown when 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.ts edit detected by ContentWatcher resets the scenario cache, closes the HTTP/2 server, re-discovers, rebuilds and re-registers with force: 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, the pipelines_build_failed gauge → Pipeline Build Failed alert); the next successful build clears it, and while it is recorded any scenario or FEATURE.md edit retries the build
  • Pipeline SDK (@omnislash-ai/pipeline-sdk) — the brain-facing helper surface, each helper journaled through ctx.run, all working identically in all three primitives
    • runAgent / safeRunAgent — durable agent turns via a parent awakeable settled by the agent VO; runAgent throws TerminalError on agent failure, safeRunAgent returns the failure as a typed value (the safeParse mirror) for fallback / retry / branching
    • Typed structured output — outputSchema is 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 as structuredOutput; a missing or non-matching output gets correction turns — the same agent is sent the issues and its rejected output (outputCorrections: 1 → MANIFEST pipelines.outputCorrections → the call, 0 = none) — then throws terminal in runAgent and returns outputError in safeRunAgent; runtime-only shapes go through z.fromJSONSchema
    • runAgentWithFallback — cross-mode agent chains that advance on any non-completed status or on output failing the shared outputSchema once an attempt’s correction turns are spent
    • runWorkflow / 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 with scenario_start) and waits for its terminal across every attempt — parks on a person or a retry included; runWorkflow throws on a failed / cancelled run, safeRunWorkflow returns the WorkflowRunOutcome; cancelling the pipeline cancels the run; refused under ctx.loopLaunched
    • awaitApproval / requestApproval / awaitApprovalDispatch — blocking approval (suspends on an awakeable, returns the tapped actionId, supports custom action sets; the card expires after 168 h unless pipelines.approvalExpiresInHours or the call’s expiresInMs says otherwise) and detached approval (returns hitlId immediately for hook-driven follow-up)
    • askQuestion / requestGrant / requestInput — the other three HITL kinds, each awakeable-backed and typed
    • callTool — 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_render in its speech: 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 chain opts.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 by opts.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 chat
    • emitEvent — typed emission of any pipeline:* event onto the engine bus; emits nothing under ctx.loopLaunched
    • reportProgress — validated {percentage?, stage?, message?} written to pipeline_invocations.progress (replace, not append) plus a liveness heartbeat, emitted as pipeline:run.progress, and deliberately excluded from step rows
    • reportRunSummary — a synchronous ALS-only human-facing one-liner shipped on pipeline:run.completed; display metadata that is never journaled and cannot break determinism
    • subscribeResource / 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 whose next({ timeoutMs }) answers { kind: 'events', events } or { kind: 'timeout' } — events recorded while the pipeline was busy elsewhere are handed over by the next next, nothing falls between waits; close() it in a finally. waitForResource is 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 the policy says otherwise); the loop’s merge wait runs on it
    • kvGet / kvSet / kvDelete — PostgreSQL-backed persistent key-value storage namespaced by scope, with an optional expiresAt TTL, for cross-invocation state; kvSet / kvDelete write nothing under ctx.loopLaunched
    • recordSpend — books money the pipeline paid outside any agent turn (a metered API it called) on the spend ledger as a kind: 'external' row attributed to the invocation; recorded once across replays and retried writes
    • runtime() 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-sdk as restate so brain and engine share one module instance and therefore one HANDLER_SYMBOL identity
  • 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’s PipelineHandlerMeta.inputSchema used to render trigger drawers; an optional describe string becomes the owner-facing handlerLabel on the run card
  • Handler serde + kind preservation — the engine re-wraps every handler for instrumentation, and rewrapPreservingOptions re-applies the original HandlerWrapper.{kind, options} through the matching SDK factory, so input serde survives and a VO shared handler is not silently downgraded to exclusive
  • Declarative pre-run input collection — a workflow-primitive pipeline scenario declaring inputSchema in frontmatter gets a HITL form before the pipeline starts on the Telegram /run deep 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 beside default lets a pipeline declare the chat for everything it emits by itself (its live run card and every notify without a surface / chatId / to: 'invocation'; botId optional, the chief’s bot by default), winning over trigger meta and scenario surface and inherited by its child invocations (null declares 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 unknown pipelineConfig key fails discovery
  • Automatic causal-tree propagation — the engine wraps the handler ctx and injects trigger meta (rootRunId / parentRunId) into all six client methods, so child invocations spawned with plain ctx.workflowClient / objectClient / serviceClient (and their send variants) render inside the parent’s card with no manual wrapping
  • Live agent progress in run cards — runAgent bridges the agent’s Redis output stream from the pipelines process (outside ctx.run, idempotent per agentId, self-stopping on turn.completed) and republishes non-terminal events as pipeline:step.agent_event carrying (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, with slug, kind, objectKey, status, input/result/error, totalStepCount, progress, lastProgressAt (progress liveness), pause/resume/cancel timestamps), pipeline_steps (one row per user-visible ctx.run, with monotonic stepIndex, error class, duration, journal index and an agentId FK), pipeline_events (timeline audit trail written synchronously alongside the live emit) and pipeline_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.run steps; Restate’s own journal stays the source of truth
    • Replay-safe step rows — persistence and step events execute inside the Restate ctx.run they describe, so a replay returns the cached result and produces no duplicates; an explicit retryPolicy deliberately yields one row per attempt
    • Large-payload blob offload — a step output over config.pipelines.blobThresholdBytes (64 KB) is written to pipeline-runs/<slug>/steps/ and persisted through BlobStorageRouter, stored as a StoredPayload blob ref; non-serializable values degrade to a sentinel rather than aborting observability
    • Derived current step — findCurrentPipelineStep(runId) computes the newest running step on the fly instead of storing a currentStepId, because RestatePromise.all legitimately 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, correlating Run journal 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)
  • Pipeline lifecycle events — pipeline:run.{started,completed,failed,cancelled,paused,resumed,killed,progress} and pipeline: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_start on a pipeline scenario (optional idempotencyKey, passed raw as Restate’s idempotency key: a repeat within the service’s idempotencyRetention answers the first invocation with existing: true; a Workflow run dedups on its key), 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 by rerun_of) — all keyed on the DB run id, resolving the Restate invocation id internally, with PipelineLifecycle applying status CASes so pausedAt/resumedAt/cancelledAt stay 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 own options.retryPolicy replaces the default whole (Restate merges service options shallowly)
  • Paused invocations surfaced — the orphan reconciler projects Restate’s paused onto the pipeline row (running ↔ paused, with the card’s pipeline:run.paused/resumed), and restate_invocations_paused{restate_service} feeds the critical Restate Invocations Paused alert, 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 by runtime_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_kill for a pipeline, agent_stop for an agent turn, run_retry for 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.run is retryable and replays from the failure point; a throw that leaves the handler fails the run on that attempt (the engine settles it failed and reports it terminal), while a restate.RetryableError replays it; restate.TerminalError stops 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 a CancelledError rejects the await over ctx.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, the agent Virtual Object, the agent-limiter Virtual Object and the greeter / agent-demo / approval-demo demos are served from the same endpoint; ENGINE_BUILTIN_SERVICE_NAMES is the single allowlist and drift against getEngineServices() 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 — reconcilePipelineOrphans closes the three holes the terminal write cannot: a deleted .pipeline.ts with 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 yet completed) whose service is absent from the current discovery snapshot and is not an engine built-in is killed, and its row then settles as cancelled; 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/paused row is reconciled against sys_invocation (success → completed, a Restate cancel/kill ([409]) → cancelled, other failure → failed, in-flight → only its running/paused split 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-run default, --apply, --json)
  • 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 ctx operations 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 as run_kill kills it, pages, watches and cards included) or restart (reset as run_reset — the steps before the wait run again, started as the original was: same person, same chat; a keyed restate.workflow run and a run another run started are ended instead) → MANIFEST pipelines.onCodeChange → the scenario’s execution.onCodeChange; no card — the reason on the run’s error, 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, else run_reset (a workflow-kind run: run_kill + scenario_start under a new key)
  • Developer CLI — omnislash pipeline run <name> [input] (with --handler, --target-key, --idempotency-key) and omnislash pipeline status <key> --name <name> drive the Restate ingress directly
  • Container & runtime — a dedicated pipelines service (HTTP/2 on PIPELINES_PORT, health/metrics on HEALTH_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 outputArtifacts are 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 no dependsOn graph 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 and formatManifest(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_SUBTREES names every subtree and its lifecycle (store vs scratch), and every writer resolves its directory through getArtifactSubtree(key): blobs/ (store), blobs/.tmp/, runs/<slug>/, agents/<id>/, telegram/<bucket>/, conversation-uploads/, pipeline-runs/<slug>/steps/
    • Derived coverage — retention, the /artifacts browser’s hidden roots and getReapableArtifactDirs() 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
  • Artifacts retention reaper — the scheduler sweeps every scratch subtree under a brain-configured artifactsRetention policy in omnislash.config.ts
    • Two independent optional dimensions — ttlHours (age cut, runs first) and maxSizeMb (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 opendir so 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) and runs/ (interleaves with in-flight runs, needs a lifecycle-aware sweep) are excluded; omnislash doctor warns when the whole tree exceeds 5 GB
  • 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 into blobs/.tmp/, streams a sha256 of that copy, then renames it to blobs/<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
    • sizeBytes from the stored copy — measured on what the ref will actually serve, not a separate stat of a source someone else may have rewritten
    • maxBytes enforced during staging — an oversize file throws BlobTooLargeError before 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_NOFOLLOW so a symlink at the final component fails instead of being followed into the store; every caller hands persist() a realpathWithin result, and the inline (non-blob) text paths canonicalize the same way
    • retrieve() refuses any path outside the store root — canonicalized through realpathWithin, so a blob ref can never be made to name an arbitrary file on disk; a missing blob still surfaces as ENOENT
    • Refs are absolute paths, not bare hashes — deliberately, so the API stream reader and CLI agents can Read them natively and the original filename stays visible; content addressing lives in the directory layout
  • 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 (collectLiveBlobRefs over LIVE_REF_SITES: published artifacts, run deliverables, task artifacts, pipeline step outputs, conversation-event attachments, agent inbox attachments) and LocalFileBlobStorage.sweep() quarantines unreferenced blobs older than the grace window (blobGc.graceHours, engine default 72h, brain-tunable) into blobs/.trash/, purging quarantine a full second window later
    • Liveness by basename — refs match on the content-derived <sha>-<name>, so a moved ARTIFACTS_ROOT cannot 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.ts source-scans the schema so a new ref-bearing column fails CI until classified
  • BlobStorageRouter with pluggable providers — refs carry a URI scheme (local://, future s3://) 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, and createBlobStorage() 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 onto workflow_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
  • Task outcome envelope — EVERY task is dispatched with one structured-output schema (buildTaskOutputSchema): {success, error, summary, artifacts}. artifacts[].filename is an enum pinned 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, and deliverable.schema is applied to that file afterwards (checkDeliverableContent); success:false + error is 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:false verdict fails the task immediately with the agent’s own error as the reason, in its own branch past the infrastructure-failure branch so classifyError never reads it; a blocker that happens to say “rate limit on the vendor API” can no longer push a business verdict into awaiting_retry and 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-outcome issue into the same list checkMissingArtifacts fills, so it inherits that seam verbatim (one recovery re-prompt, then fail); buildRecoveryPrompt branches on the key, keeping the file-oriented “do NOT start over” wording to missing-artifacts
    • optional: true still 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
  • Artifact validation & auto-recovery — checkMissingArtifacts (part of DEFAULT_CHECKS) probes each declared file on disk after a task’s agent completes and, on a miss, emits a missing-artifacts issue 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() injects storage so it is never an LLM decision
    • Storage strategies — inline (text content in a JSONB column, the default), blob (URI ref + sizeBytes recorded at persist time so senders can gate on platform limits before materializing) and none (the action itself is the result; resultSummary is the only user-facing output)
    • Format-driven routing — the closed TEXT_FORMATS set decides text-vs-binary; anything outside it (including unknown formats) goes to blob storage
    • Scenario configuration — deliverable: { storage, format } in scenario frontmatter, both optional; format becomes a HARD CONSTRAINT injected into the planner’s deliverable stage, while filename is always the planner’s job
    • Execution — resolveDeliverable() builds a ”## Final Deliverable” prompt section with the full path (and parses a JSON Schema into an outputSchema for format: json), given only to the last task
    • Completion — the run handler’s journaled finalize-deliverable step runs resolveAndStoreDeliverable(), storing a blob ref or inline content on workflow_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 — storedDeliverableSchema is a Zod discriminated union on storage whose shared meta (description, filename, format) is derived from DeliverableSchema; storedArtifactSchema mirrors it at task granularity
  • 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, and taskRun.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; filters status, visibility, publisher actor 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 — 0 expires now, null permanent, 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) and artifact_delete (drops the record, frees its name — artifact:widen too); every write is an action-log row against artifact:<id>. The dashboard downloads a publication whole at /artifact-file?id=
    • Readable names, not secrets — every publication carries an alias served at /a/<alias> (derived by the fast model chain from title ?? filename stem, slugified, suffixed until free, or caller-pinned and validated against ARTIFACT_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), returning 401 + WWW-Authenticate so 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 by omnislash 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 — replaces re-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); expiresInHours stamps a deadline that replaces re-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:publish and the id may replace, edit or revoke (and holding artifact:widen rename or delete), which is what makes cross-actor revoke work; published_by / replaced_by / revoked_by and the action log are audit, never authorization
    • Widening is a person’s — a content replace never moves the name or the audience; artifact_update edits 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 — both artifact_widen under artifact:widen (restricted, hitl: true), which artifact_delete sits under too; the private direction is refused when admin credentials are unset
    • Rendering table — .md/.markdown render to HTML through markdown-it with raw HTML escaped, .html/.htm pass through as authored, everything else is served raw with a detected MIME; /raw always downloads, and markdown over maxRenderBytes (1 MB) falls through to raw so a synchronous render cannot stall the webhooks event loop
    • Sandboxed pages — every response carries default-src 'none', sandbox without allow-same-origin (opaque origin, no cookies/storage/network), nosniff, no-referrer and a noindex, nofollow header, so a published page can render but cannot exfiltrate; img-src names the artifacts origin literally (never 'self', which matches nothing inside an opaque origin) plus data:, so a page loads the tab mark and images published beside it and nothing else
    • Exact caching — a strong ETag of (id, updatedAt) with Cache-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 — path must resolve (via realpathWithin, not lexical containment) inside the caller’s own artifact roots — agents/<agentId>/, plus runs/<slug>/ for a workflow task — never the whole $ARTIFACTS_ROOT; file_send draws the identical boundary
    • Size cap — files over config.artifacts.maxPublishBytes (25 MB) are refused during blob staging, so nothing is stored
    • Conditional registration — artifact_publish registers only when ARTIFACTS_PUBLIC_URL resolves (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 — your assets/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:publish at risk: 'moderate', hitl: false (there is no destination argument; the origin is engine-owned config and the act is revocable), overridable to hitl: true in constitution/engine-capabilities.json; artifact:widen at risk: 'restricted', hitl: true, granted to no role by default
    • Serve observability — every request to /p/* and /a/*, including refused ones, is counted once as published_artifact_requests_total{handle, variant, outcome} and logged once with the ref the reader used (per-link attribution deliberately in logs, not in an unbounded metric label)
    • Data model — omnislash.published_artifacts with blob_ref, filename, content_type, size_bytes, title, unique alias, expires_at, visibility, and the published_by / replaced_at / replaced_by / revoked_at / revoked_by audit columns

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, custom requirements and the deliverable; the overall score is 0.6 × task average + 0.4 × run score and 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 as eval.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 runKind discriminator plus a CHECK constraint keeps pipeline evals free of per-task results
  • 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 plus measure() / window(), passed is read off the declared floors, quality is only reported and no quality guard is added, and no sample.maxPerDay ceiling is owed (nothing is spent). A judged metric, quality as objective/guard, or requirements fail the load. The cycle pins a judge of none plus the loop module’s digest, so editing measure() mid-cycle is a judge change
  • Eval methods are brain files — constitution/Evaluation/Methods/<method>.md selected by evaluation.method, requiring a # Run Evaluation section with an optional # Task Evaluation one; add a method with eval_write_method (dry run first — a file without # Run Evaluation is refused)
    • eval_write_method (config:write) — writes one method file, validated as the loader parses it (a # Run Evaluation section), D30-shaped (dry run → expectedSha → compare-and-swap); none is reserved. The dashboard’s Eval Methods page calls the same op
  • Per-entity evaluation config — an evaluation: frontmatter block with enabled, method, modelRef, run-level requirements and sample, resolved across three tiers (engine default → constitution/MANIFEST.yaml → the entity’s own block) merged one level deep with sample merged field-by-field
    • Manifest kill switch — evaluation.enabled: false in 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
  • 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.metrics is 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 role as well as kind and direction — MetricSpecError naming the missing key otherwise, surfaced per entity by omnislash doctor → Loop modules. Defaulting it to observe was 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: period means 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 and window() 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. period implies computed and rules out critical (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, which omnislash doctor → Loop check names
    • Engine catalog — quality (today’s overallScore), passed (today’s overallPassed), cost_usd, duration_ms, tool_failures. Measured for every graded execution whether or not anyone declared them, so a metric promoted from observe to guard already 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 runtime enum in 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 / overallPassed are 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, quality becomes 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 — quality objective, passed guard, 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, so quality cannot leave its 0..1 scale whatever pair is chosen; declared as a pair, since half a split silently re-weighs the other side
    • aggregate: median | mean — how a metric’s per-run values become one number in the window’s level, the evolve watch’s bootstrap and the pre-merge check’s movement over its inputs; engine tier median. mean is 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) or judged with human: 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, so target / floor / tolerance are written on 0..1. Ratings live in run_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), and eval_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 holds evaluation.human.agreement.minPairs rated 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 a reported metric is the objective of an entity whose check replays
    • Rating cards, calibration, earned autonomy — evaluation.human — ask: never | always | until-calibrated (engine never): 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 in run_signals keyed by the card. The carded run waits expiresHours (48) before its judge’s number counts alone. until-calibrated stops carding once the judge agrees with people — per judged human: true metric, over the newest agreement.maxPairs judge/person pairs under the current judge (model, method, role — a .loop.ts edit keeps it), unknown below minPairs, ok at threshold agreement within tolerance — keeping spotCheck (0.1) of runs, and starts again when one falls to low. loop.autonomy.requireCalibration holds both auto gates for a person until every such metric is ok. The status is visible to your window() hook (ctx.calibration()), on each round’s record, on the dashboard’s Policy view and in omnislash doctor → Human calibration
    • optional: true — a computed metric its code may not always be able to measure: a live measure() or a ready window() that leaves it out records no observation instead of failing the grade. Refused on a judged metric
    • Tolerance axes move together — naming either tolerance or tolerancePct on 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.ts and 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. measure produces numbers the engine cannot derive (a delivery rate out of the brain’s own tables, a business KPI), verdict replaces the pre-merge check’s candidate/incumbent comparator outright, shouldOpen replaces the trigger’s k-of-n count and answers { open, reason }, cases (workflow scenarios only) are golden inputs every candidate is measured on, window chooses what a cycle is evidence of (asked on every landing grade and on every scheduled look, before shouldOpen; the repair preset samples the entity’s last trigger.windowRuns graded runs through the grade in hand, and a module composes it with ctx.preset.window(ctx)), and compare is declared for the post-merge comparison. A RUN-scoped computed metric declared outside the engine catalog REQUIRES measure — the evaluation fails naming the ids rather than scoring against a metric nobody measured — and a PERIOD-scoped one requires window, 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 like tools/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. measure is 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
  • Eval sampling policy — evaluation.sample decides how much of an entity’s execution stream is graded
    • rate (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 ask
    • maxPerDay — a per-entity cap over a rolling 24h, so a chatty scenario cannot starve the rest of the brain; rate alone bounds the fraction but never the count
    • One shared decision — decideEvaluation sits 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.maxPerDay is optional for human-launched scenarios and required once the engine produces the executions (a scenario with schedule:, and every heartbeat)
    • Enforced where the money is spent — decideEvaluation returns no_ceiling and grades nothing, so no write path (dashboard config editor, scenario_update tool) 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 doctor names every unattended entity in that state under Eval ceilings
  • Booked before queued — a producer that decides to grade inserts the pending evaluation row before enqueueing the job, so the row is the reservation maxPerDay counts; 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.maxPerDay counts and the queue carries the lag as a BullMQ delay; a lag of 0 passes 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 still pending past due_at plus the queue’s own retry span whose job it has confirmed is gone from the queue, with error: '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 a sample.maxPerDay slot: that count is status-blind and expires with its own rolling 24h window either way.) Rows with no due_at are 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_start tool, which PROMOTES a grading already waiting out its lag rather than silently deduplicating against it
    • omnislash doctor bounds the lag against what the brain keeps — Eval ceilings fails a heartbeat whose lag outlives agentRetention.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 when watch.deadlineHours cannot 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.replay is true when the loop re-ran a frozen input inside its check; a brain’s measure() 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
  • Run identity for lineage — ctx.runRef / context.auth.runRef — every graded execution hands brain code the { runKind, runId } its measure(run) will later receive, so a late metric can join on rows the run wrote: a pipeline reads ctx.runRef (its own invocation, plus definitionHash — sha256 of its scenario file and primary *.pipeline.ts as it started, replay-stable), a brain tool reads context.auth.runRef (the workflow run for a task’s agent, the invocation for callTool / 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 chat kind’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 as context.auth.runRef; group chats and system roles have none
    • Idle close — chat.episode.idleHours (engine 24, MANIFEST § chat → role chat:) — 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 (the agent.close_episode op, capability episode: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, and closeChatEpisode(runId) from @omnislash-ai/infra/chat-episodes for 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 (engine keep) instead ends the open episode role-change and starts the session over on the first message after the change
    • Graded when it ends — the episode’s grading is booked at its close, gradeAfterHours counted from there; an idle close racing an explicit one still books once. Unattended like a schedule: a chat role owes sample.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
  • Chat roles in the loop — a role whose *.agent.md declares evaluation: / loop: is a chat loop entity (roster, <role>.loop.ts, its role file walled off from other entities’ changes — its skills stay shared, as a scenario’s do; its chat: block protected like the loop blocks); every per-entity doctor check reads chat roles beside scenarios and heartbeats; system roles are not entities, and omnislash 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’s runId, shape and verdict, and it reads the dialog itself with db_query (proposer/chat.md § Reading the dialogs) — a role copy without db:read keeps 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, autonomy decides its gates
    • The owner’s rating — evaluation.human.ask (engine never); 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>
  • 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, gradeAfterHours from 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 evaluations table keyed (runKind, runId) with a denormalized targetId (the recurrent entity) indexed for the rolling-window count, storing the metrics vector, its overallScore / overallPassed projection, per-task results, the run result, a summary and the createdBy actor
  • Every grade records WHO produced it — each evaluations row carries a judge snapshot: 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 on loop_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 modelRef is the entity’s evaluation.modelRef or, when it declared none, the evaluator role’s own inherited ref, with every role step expanded to the concrete chain models.json resolved 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 in models.json mid-cycle would change who grades without changing the string. Dispatching the pinned ref reproduces dispatching the role at the moment it was pinned
  • The judge is frozen onto the cycle — a cycle resolves its judge ONCE at the start (loop_cycles.judge jsonb), 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 proposal skipped with 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 settles unvalidated with reason judge unhealthy plus 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 is ungraded for 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
  • A cycle freezes WHAT it grades beside WHO grades it — loop_cycles.frozen (jsonb FrozenSet) is written once, right after the judge: the entity’s last loop.check.inputs graded runs through the window’s anchor — the evaluation of the window’s newest run, scanned HISTORY_SCAN_LIMIT deep 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 from defineLoop({ cases }), plus baseSha — 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 to baseSha — one rule, no knob. Never recomputed: a refinement round reuses it. A set the brain’s files will not yield settles the proposal skipped with a notice, like an unresolvable judge
    • The freeze owns the evidence/held-out split — the same scan’s head, evidence.runs deep, is recorded as FrozenSet.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 the check.inputs history slots fill anchor’s input first (a repair fix 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 names held-out k of n, and k = 0 is a doctor hint under Loop check (check.inputs < 2, or distinct graded inputs ≤ evidence.runs). No new key: the seam is the existing pair evidence.runs / check.inputs
    • loop.check seam — { mode: replay | none, inputs (1..20, default 5), trials (1..3, default 1), deadlineHours (default 24) }, merged wholesale per tier like autonomy. mode carries NO engine literal: it is derived per cycle from the plan by resolveCheckMode — replay for a workflow scenario whose .blueprint.yml needs no capability a human has to grant (a replay asks nobody and fails on one), reaches no capability above moderate (REPLAY_RISK_CEILING), opens no PR and runs in the brain repo (agent.repo absent or the brain itself); the facts are read off the plan once, in @omnislash-ai/capabilities (readCheckSubject), for the doctor and the freeze alike; none otherwise and always for a heartbeat or a pipeline, whatever a tier declares. A declared replay on an unreplayable workflow is honoured; under none the cycle freezes nothing and frozen stays 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 — fail on replay declared in an entity’s own frontmatter for a heartbeat or a pipeline, and on replay declared for a workflow with no readable plan (the freeze would skip every cycle); warn on replay declared 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 derives none and checks nothing), and on a replayed entity with fewer inputs to freeze (distinct graded inputs + golden cases) than check.inputs; pass with a hint naming replayable workflows that declared none. 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 a warn, never a fail
    • .blueprint.yml is 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 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 to frozen.baseSha (the fresh incumbents, baseline_run_ids), waits for their grades up to check.deadlineHours, checks the judge (maxUngradedShare, candidate side) and that the incumbent was measured at all, then hands the comparator the inputs × trials pairs — 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: write in ENGINE_GIT_APP_PERMISSIONS): pending when the check opens, success on validated, failure on regressed, error with the reason on unvalidated. A repository that requires it cannot merge an unchecked candidate, nor a commit pushed after the check (the status is on checked_sha alone); under merge: auto the 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’s expectedHeadOid — 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 a head-moved notice
    • The comparator reads PAIRS — VerdictInput.pairs, one per frozen input per check.trials, each carrying the incumbent’s side (its frozen grade, or the fresh replay at baseSha) and the candidate’s (PairSide: viable with 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
    • regressed closes the PR — gh pr close with the per-metric and per-input story in a comment, the row settled from applied through the same CAS the watch uses (settleCycleValidation(…, 'applied')), a lesson written, no revert: the change never shipped. unvalidated leaves the PR open for a person, with the round demoted to the human merge gate under merge: 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 out check.deadlineHours to settle unvalidated
    • 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 applied row whose check has opened (checked_sha set) is bounded by watch.deadlineHours + check.deadlineHours, since the merge wait starts only once the check answered
  • 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-text failureDetail; 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 — EvalChannel reduces workflow:eval.* / pipeline:eval.* events into a Redis evaluation: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_failed on both the workflow and pipeline buses, plus per-task workflow: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 the runId it grades, with its live per-task progress while it grades) and eval_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.0 eval_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 sha eval_write_method swaps against), eval_list_episodes / eval_get_episode (a chat role’s episodes with the judge’s grade and people’s ratings) and eval_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, its grade and each round’s baseline / candidate pairs — 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 }) and loop_cancel_idea ({ ideaId } → { withdrawn, reason } — the idea’s state after the call, so a repeated withdraw answers true again), in the same Evaluation category and registration as the eval tools, so worker, pipeline, chat and MCP agents all carry them; both provide loop:analyze (moderate, hitl: true — an idea spends a proposer and outranks the entity’s own rule; relaxed per automation with a grants: block or brain-wide in constitution/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 with opened: 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, runIds distinct and oldest first, period metrics, reason) is sampled by the loop’s window function through ONE producer (openCycleWindow, @omnislash-ai/loop: enabled first, then window → engine re-filter → shouldOpen, the live-target guard, the keyed submit, never throwing on brain code; its decision half is evaluateTriggerWindow → {kind, window, open, reason, ruleSource}, which a reader outside a grade — the dashboard’s entity detail — asks with anchor: null for the tick/owner answer, brain-replaced window/shouldOpen included) and read by everything after: the brief, the baseline (windowVector — each metric aggregated over the window’s runs, the median unless it is declared aggregate: 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 as rejected, and the row stores the survivors — the anchor a stored window is read through is always a run the loop counted. evaluation_id is the grade that produced the window, nullable; a window with none is analysed on its runs alone, carries no grade.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 own runs.json, loop.evidence could not mean one thing: a brain raising runs on 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.json is the entity’s last evidence.runs GRADED 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 own resultSummary, the failure taxonomy and — under evidence.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 — estimateTokens over every file, and over evidence.maxTokens the 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.json and metrics.json are 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.proposer block — { pipeline, modelRef, instructions }, the one block inside loop: 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 a pipeline); 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 as ProposerBrief.instructions: { brain, entity }. The retired flat keys loop.modelRef, loop.prompt and a scalar loop.proposer are refused at load with the new path in the message. omnislash doctor (Loop proposer models) fails a modelRef models.json does not resolve and warns on one beside a pipeline
    • 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) declares auto per 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_cycles row: 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 through loop_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, plus skipped and failed. A regressed reached by the pre-merge check settles the row from applied: 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: idea for an idea cycle, else the preset’s, else the entity kind’s — proposerBriefPath), constitution/Loop/implementer.md, and constitution/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 doctor fails a brain copy at a retired path (Loop/{default,apply}.md, a flat Loop/<brief>.md, constitution/Improvement/** — Loop prompt paths) or carrying a placeholder the engine does not fill / an implementer.md without {{editablePaths}} (Loop prompt placeholders)
  • Two presets — the loop is a skeleton plus a bundle of functions — loop.preset names the engine tier of window, shouldOpen, verdict and compare (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 to
    • repair (the default) fixes what broke: a bad graded run opens an analysis over the entity’s last trigger.windowRuns grades, the proposer is shown the newest runs bad-first, and the post-merge watch counts bad runs by the trigger’s rule
    • evolve improves what works: every trigger.windowRuns grades (or every trigger.every hours) 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 under constitution/Loop/proposer/evolve.md; a threshold or trigger.minFailures written on the entity (or beside a brain-wide preset: evolve) is refused at load, while a brain-wide one tuned for repair entities is simply not read
    • The evolve watch compares windows — the window the cycle opened on against the first watch.windowRuns runs graded since the merge: floors and guards as point rules on the median, the objective through a bootstrap interval at watch.confidence (engine 0.9) — regressed only when the whole interval is past the tolerance, validated only 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 change unvalidated naming it — never regressed, never validated. MetricDelta.spread carries the interval beside the gain wherever perMetric is shown; the watch stamps no validation_terms under evolve
    • An unproven change — watch.onInconclusive: keep | revert — what a shipped change the watch settled unvalidated becomes (a gain inside the noise, a window that never filled, an unhealthy judge): keep (engine tier) leaves it shipped as unproven; revert undoes it through the same revert PR a regression gets, so an autonomous brain keeps only what was shown to work. The row still settles unvalidated; the dashboard’s cycle view says “reverted by policy”. Not read under watch.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 each scope: period id 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_status needsYou, gate window, 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 for loop.windowStalledAfterHours (engine 336 h, MANIFEST → entity, 0 never) is listed as a stall with 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 an evolve entity whose only comparable metric is the judge’s quality (optimised against its own grader), and hints the quality: { 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) with targetKind the ENTITY (scenario, heartbeat, role — LoopTargetKind, @omnislash-ai/types/entity), never the engine that runs it, so a scenario that gains a .pipeline.ts keeps its cycles, lessons, ideas, holds and eval window (evaluations.target_kind is generated from run_kind). One LoopTarget adapter 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 (the loop: 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’s check_verdict all 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 effective merge_gate and 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 status refined, and a failed round retried goes back to accepted on 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” reads revert_pr_url through the same join, and every lifecycle event frame carries proposalId beside prUrl so 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: observed runs through to), loop:revert.settled (how the wait on a regressed round’s revert PR ended: merged at GitHub’s mergedAt, closed or unmerged), loop:merge.gated (the merge gate the apply PR really stands under was recorded — gate: human | auto and waitStartedAt, the instant a person’s merge wait is counted from) and loop:merge.demoted (an armed auto-merge lost mid-wait — cause: head-moved | disarmed, armStands when the arm may still land the PR); the dashboard invalidates on them instead of polling. Observational like every frame — the row is the record
  • 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 settles unvalidated rather 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
  • Recurrence bounds — what the loop may spend on one entity — two loop: knobs on the same three-tier seam, gated in CycleService.startAnalysis beside the claim; a refinement round is exempt and an unloadable config fails open
    • cooldownHours — rest after a SETTLED cycle, engine tier conditional on the outcome (DEFAULT_ANALYSIS_COOLDOWN_HOURS: 6h ordinarily, 24h after regressed) and so carrying no Zod .default(); 0 disables the rest
    • maxAnalysesPerDay — rolling-24h ceiling on proposer agents for one entity, engine default 3, null opts 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 skipped with the reason (CycleService.startAnalysis), so the entity’s cycles on /evolution show 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 like autonomy, engine default null (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: readBudgetAllowance prices the run’s own agents (loop_cycles.agent_ids, off the spend ledger usage_records) before each dispatch and hands that agent the remainder as AgentCliOptions.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 — deleteAgent is 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 its error_max_budget_usd terminal is named over budget; Codex and OpenCode, which accept no such option, are compared against sumAgentCostUsd([agentId]) once the turn’s terminal passes and the terminal is rewritten into the identical failed over budget one. 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
  • k-of-n trigger, never one run — an analysis opens when the graded run that just landed went badly AND loop.trigger.minFailures of the entity’s last trigger.windowRuns GRADED evaluations did (engine default 2 of 5, the block merged wholesale per tier like autonomy). 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: 1 restores “analyse the first failure”, minFailures == windowRuns is the “N in a row” rule, and minFailures > windowRuns is 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.threshold limits 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
    • shouldOpen replaces the rule, not the numbers — LoopModule.shouldOpen({ window, trigger }) on the entity’s <id>.loop.ts, or defineProject({ 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.enabled is 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 settles unvalidated naming it. LoopTerms.functions carries 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 target a 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 (findGradedWorkflowInputs reads through: 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 shouldOpen imports the entity’s <id>.loop.ts on 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 resolved every has 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 + every rounded up to the sweep, and restingUntil = last settle + the cooldown — so the dashboard answers “next look” off the same clock) and calls the ONE producer with anchor: 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 collapse every into 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, so max(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, and omnislash doctor names 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 plus loop_cycles_live_entity_key rather than by a redis lock — one live cycle per entity is an invariant the row already holds. Under repair a 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: reviewDeadlineHours is read at each analysis round and arms every one of that round’s human waits at once (and a brain proposer’s run), watch.deadlineHours once 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
  • The ideas bucket — a human’s hypothesis, next in line — loop_ideas holds 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 points postLoopIdea / withdrawLoopIdea (@omnislash-ai/loop) are the only way in or out, so every surface refuses the same things in the same words — the /idea chat command, the loop_create_idea / loop_cancel_idea tools 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 the loop_create_idea / loop_cancel_idea tools (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), cooldownHours and maxAnalysesPerDay; 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’s ideaId, 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 as tick for every enabled entity with a queued idea (one query per sweep, no trigger.every needed, no cadence mark stamped), and a grade that lands while an idea is queued opens the idea’s cycle with its grade.json riding 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 settles skipped (“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’s loop.proposer.instructions still appended — plus idea.md in the brief: the text, who posted it and when, never cut by the evidence budget and bounded at the post by IDEA_MAX_CHARS (4000) for that reason. Declining — protected surface, missing capability, a validated lesson against it — is empty promptChanges with the reason in summary; the output schema is unchanged
  • Two deadlines, not one — loop.reviewDeadlineHours (168h) bounds HUMAN latency at each wait of an analysis round (none of which exists under autonomy.decide: auto, where a grant request fails the turn and a broken analysis or apply closes the cycle failed with no card), all armed from one read per round: a grant request the proposer parks on abandons the turn and records the analysis failed; an Accept / Skip card nobody answers inside it expires (the card’s expiresAt IS the deadline — one clock) and the cycle settles skipped unanswered, with a review-abandoned notice, 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 apply failed, 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.watch block (mode: observe | none, windowRuns / minFailures defaulting to the trigger’s pair, deadlineHours 168), merged wholesale per tier like autonomy — 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 than ceil(inputs × trials / 2) pairs judged — the incumbent graded and the candidate answering: graded, or launched and dead before a grade — → unvalidated insufficient 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) a critical metric past tolerance on ANY input → regressed, naming the input — the per-input veto the median cannot forgive; (e) a guard past 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 declared floor → regressed; (g) the objective past tolerance → regressed, and under mustImprove a gain inside the noise across inputs (half the spread of the per-input movements, never tighter than the tolerance) → unvalidated; (h) otherwise validated. 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.perInput records every pair — what each side produced, whether the candidate lost the input, which critical metrics it failed there — beside perMetric
    • The four buckets travel whole to the comparator — failed / ungraded / notRun / unfinished plus expected (how much evidence was PROMISED), instead of one summed inviable. 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 reached regressed and opened a revert PR. unfinished and notRun are 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 a critical metric, 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 over loop.watch — windowRuns runs, minFailures bad ones — whose pair is folded from loop.trigger unless 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 doctor warns under Loop watch when an OBSERVED entity (a heartbeat, a scheduled pipeline) has a cadence that cannot deliver watch.windowRuns executions inside watch.deadlineHours; watch.mode: none is exempt
    • mustImprove on 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 an objective, it settles a change that moved nothing unvalidated rather than validated, 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 bare gain > 0 test 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 demotes quality to a guard when another metric becomes the objective, so a sibling’s role change would otherwise start failing a declaration that was legal when written
    • floor is the absolute bar a tolerance cannot state — per metric, optional: with direction: max the challenger median may not fall below it, with min it 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 from regressed on the delta, because a metric can sit below its floor without having moved at all. Read on objective and guard and inert on observe, 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’s window (phase: 'watch', since the watermark — under repair the first watch.windowRuns runs 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’s compare over the cycle’s window against it. null is “not yet” and the watch keeps observing; a VerdictResult settles the row on the spot and is stored verbatim as watch_verdict beside validation_run_ids (the settled window’s runs) and, under repair, validation_terms. repair’s compare is the trigger’s question (triggeredWithin): by the engine’s count of bad runs — the trigger’s own definition (isBadRun: failed, below threshold, or a missed metric target) — or by the brain’s shouldOpen where the entity declares one (the resolved function’s source is what tells the two apart), asked at every grade. minFailures bad runs settle regressed the moment they are counted, whatever the rest of the window brings (withheld only while the judge looks broken — the engine’s own read, which settles unvalidated at the deadline instead); a full window with fewer settles validated; a window the deadline closes short of both settles unvalidated — with an empty perMetric, which is how the autonomy breaker reads a repair unvalidated as 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 from window or compare settles the row unvalidated naming the function — never a killed invocation. The check’s record stays on the round (check_verdict); the lesson reads the watch’s perMetric first, 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 why repair counts. 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. launch is gone from ValidationStrategy: nothing is re-executed after a merge
    • watch.mode: none is declarable — the honest answer for an entity the brain would rather not watch. A change it ships settles unvalidated by design, with an unvalidated-by-design notice — explicitly “not checked”, never mistaken for “checked and fine”
    • unvalidated is a real terminal, and the comparator may now reach it — a watched window the deadline closed short with fewer than minFailures bad 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 but regressed opens a revert. MetricVerdictFn returns a discriminated union — validated carries reason: null, the two negative outcomes owe a string — 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 own deadlineHours is never charged against it
    • Automatic revert — regressed opens a mechanical revert PR (merged through the same gate the change shipped under — autonomy.merge: auto arms auto-merge on it too, else a human merges), whose URL lands on the round’s revert_pr_url — a field of its own, because pr_url is 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 by watch.deadlineHours from the revert’s opening) and records revert_outcome (merged / closed / unmerged) and GitHub’s revert_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. Under merge: auto the 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 an unvalidated, 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’s cycles attach watch — each observed run as good / bad / ungraded under 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 a windowRuns or threshold moved 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_at beside started_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_at alone could not carry them: it means “this row is done” and is rewritten by whichever transition finished it last, erasing every earlier boundary
  • Protected surface, applied before the write — one resolved ProtectedSurface — the turn’s PathConfinement { editable, denied, unreadable } plus maxChangedLines — per implementer turn, rendered in three places: the CLI harness’s native permissions (compiled from the implementer role’s file:read + file:write grant plus the surface — an edit outside editable is 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, so omnislash doctor fails 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 under isolation.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 — editable is the accepted proposal’s own target files plus skills/<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 — denied is constitution/Evaluation/**, constitution/Loop/**, constitution/Agents/system/**, any *.loop.ts, constitution/models.json, omnislash.config.ts, memory/ and every declared memory.sources entry (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/, plus loop.protectedPaths from MANIFEST.yaml, which ADDS and never removes; denied beats editable
    • The judge is closed to reading too — constitution/Evaluation/** and *.loop.ts are unreadable: 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’s db:read reaches the judge’s recorded turns
    • The proposer reads the runs itself — loop/role-proposer.md grants file:read, db:read (db_query, db_schema) and loop: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 its runId. A brain copy without db:read confines 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.md included) carrying '*' or engineering:execute hands 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 is failed naming 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 the loop: 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 of constitution/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, no gh token 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/config and HEAD (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 on loop/<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 is git status, with hooks off. omnislash doctor fails 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
  • Lessons are rows, not prose (compounding) — every terminal round that had a proposal behind it writes one omnislash.loop_lessons row: 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’s check_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, skipped and failed; 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 retried failed round settle a second, different way
    • Citations make a lesson measurable — the proposer answers with citedLessons, ids out of the lessons.json it 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 lesson helpful on a validated round whose verdict something measured (strength paired or measured — a blind win credits nothing) or charges it harmful on a regressed one, 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 its rejection, 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 as note) or timeout (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 verdict strength
    • Recall and retention are one seam — loop.lessons (maxRecalled 10, retentionDays 180), replaced wholesale per tier like autonomy, read live at the moment each applies. maxRecalled: 0 recalls 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
  • Verdict strength — paired / measured / blind — one derivation (verdictStrength in @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, or repair’s k-of-n count with its stamped validation_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’s helpful credit, loop_history and the dashboard (each settled outcome shows it)
  • Cycle grades — Improvement / Tradeoff / Tie / Inconclusive / Regression — one derivation (cycleGradeOf in @omnislash-ai/loop, words CYCLE_GRADES in @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; blind reads 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 default null = the proposer agent; brain-wide in MANIFEST.yaml; per entity in frontmatter, where null takes the proposer agent back). The loop SENDS its run handler a typed ProposerBrief (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 the loop.proposer.instructions of both tiers), cut by the same loop.evidence / lessons.maxRecalled bounds, plus the rails its files will meet — as a loop-launched run of its own, and it answers { summary, hypothesis, files: [{ path, content | null }] } or null / nothing (“nothing to propose” → the cycle skipped, no round). The answer is parsed where it lands: a shape the engine refuses, a reply that is not JSON, or a TerminalError, 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 / ProposerAnswer come from @omnislash-ai/pipeline-sdk
  • loop_history — the loop’s ledger for brain code — the op loop.history, a read-only, safe Evaluation tool under capability loop:read (hitl: false), callable from a pipeline through callTool and live under loopLaunched: one row per cycle, newest first, filtered by targetKind / targetId / feature / since, bounded by limit and paged by cursor — 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 strings
  • loop_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 merge phase 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, a pull_request closed delivery 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. The gh pr view poll (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 to pull_request, and its tick is also what re-checks the validation deadline. An event is a cue, never the verdict: it prompts an immediate gh pr view, and only GitHub reading MERGED (or CLOSED) ends the wait — anyone who reaches the inbound sink can record an event about the PR; the merge instant is GitHub’s mergedAt, never the moment the loop heard
  • Declarative per-gate autonomy — loop.autonomy declares 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; auto arms GitHub-native auto-merge once the pre-merge check has answered, so required checks stay a gate) plus mergeMethod: squash | rebase | merge. No direct merge exists: mergePrAuto answers armed or unavailable with 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 to gh pr merge itself; unavailable demotes that round to the human merge gate with an auto-merge unavailable: <reason> notice, never a silent downgrade, never a failed apply over an open PR. The engine publishes omnislash/diff-gate: success on 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) and omnislash/validation from 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 when omnislash/validation is not required, or when the repository has “Allow auto-merge” off (allow_auto_merge) while an entity declares merge: 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 the GH_TOKEN/GITHUB_TOKEN gh reads, so an ssh brain remote (an ~/.ssh/config alias included) is judged under that token; from a laptop it reads the rules with any token in the brain’s .env and 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 a warn naming 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 the unvalidated settle, 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_disabled event on the PR’s watch, and every read takes autoMergeRequest on 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 an auto-merge-disarmed notice. 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 (its synchronize event prompts that read at once).
    • Revert inherits merge — no separate knob: merge: auto auto-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 in finally, so a re-run never double-opens; the two statuses are success on the revert head, so a base branch that requires them takes the undo instead of holding it forever
    • Circuit breaker — maxConsecutiveRegressions (default 2) consecutive regressed verdicts demote both gates to human and notify, until someone re-arms (the entity’s dashboard page or loop_rearm); derived from the entity’s own verdict history (readBreaker → { streak, blind }: validated resets the streak, an unvalidated neither counts nor resets it — EXCEPT one that shipped through an autonomous gate and could not be measured (merge_gate = 'auto' on a row with merged_at — the effective gate, which nothing writes after the merge — so a watch.mode: none change that shipped on its own counts too), which is blind and trips the breaker on its own whatever the ceiling (unless the brain declared autonomy.onUnmeasured: continue): a loop that would ship the next one the same way is the failure the breaker exists for; the durable re-arm is loop_cycles.regression_acked_at on the reviewed row, acked from the entity page), never a stored autonomy state; the breaker never blocks a revert. null disables the STREAK ceiling only — blind trips under any ceiling. omnislash doctor names an unattended entity that took the opt-out under Loop autonomy, along with any auto/auto entity judged by quality alone
    • What a person owes the loop — listCyclesAwaitingPerson(rearm) (@omnislash-ai/infra): ONE statement, every gate kind — decide / retry (a pending HITL card of producer loop, data = loop.gate.v1: CycleGateDataSchema in @omnislash-ai/types/loop), grant (a pending grant_request from a live cycle’s own agent, coalesced subscribers included), merge (applied + merge_gate = 'human', due merge_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. The rearm input 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 a paused notice 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. The breaker-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 — autonomy lives inside loop:, 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 with AuthContext.unattended: HitlGateway.request refuses their HITL requests before any row exists, so a grant request fails fast and a missing_grant stays a failure (carried to CLI harnesses over the MCP URL, inherited by spawned sub-agents). A broken analysis or apply settles failed at once — no Retry / Skip card, no lesson; the breaker does not count it, maxAnalysesPerDay bounds it. omnislash doctor (Loop autonomy) names each hitl: true capability the two loop roles reach once any entity declares decide: auto
    • Policies for “we don’t know” — autonomy.onInconclusive: human | ship | close (default human) answers a pre-merge check that could not judge: a person merges, the declared gate stands (the loop posts omnislash/validation: success by policy; the cycle history shows “merged without proof by policy”), or the PR is closed and the cycle settles unvalidated. autonomy.onUnmeasured: pause | continue (default pause) decides whether a blind auto-merge trips the breaker. Doctor names merge: auto + ship + continue together. Both keys are editable in the dashboard’s Policy view
  • Kill switch — enabled: false — loop.enabled / evaluation.enabled AND across the tiers, so false in constitution/MANIFEST.yaml switches the whole brain off and no entity can override it (no brain function either: enabled is settled before window / shouldOpen). It stops NEW cycles (a grade, the trigger.every clock, 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 through loop_set / the dashboard rings every live cycle it reaches. When it reads false, the loop cancels the card, disarms auto-merge, closes the PR, and settles the cycle skipped with one notice. The idea goes back to the queue and no lesson is written. /evolution reads such a cycle as closed · 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: false also stops the grading an in-flight check or watch waits on. Documented with the in-flight moves in self-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 / releaseCycle in @omnislash-ai/loop, ackLoopFailure in its control core), behind two capabilities: the hold is loop: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 stay loop:supervise (moderate, hitl: true by default)
    • loop_rearm — the same tripped-breaker guard as the dashboard’s Re-arm; the ack records its Actor in loop_cycles.regression_acked_by, shown in the cycle history and the Supervision list
    • loop_hold / loop_release — omnislash.loop_holds rows (target: an entity, or a lock group declared under loop.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 why
    • loop_ack_failure — dismisses one terminal failure from Loop failures and records who in loop_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:read op 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; with joinOrder its 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) and loop_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) and loop_analyze (loop:analyze) are the loop.* 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_widen take dryRun and answer each file’s diff (files); loop_policy also 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_set refuses 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_widen makes the widening write once a person approves its grant, and answers each widened[] 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_status lists what each entity waits on (needsYou[]) with next: 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 the self-evolving skill — the engine cannot tell an agent’s merge apart)
  • 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.group joins 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 takes pg_advisory_xact_lock per sorted group, checks for a holder and takes the entity in one transaction; the membership is stamped on the row as loop_cycles.lock_groups). A repair member passes a repair: bypass group. A member’s watch that saw another member ship or revert after its baseline closes confounded (unvalidated, loop_cycles.confounded_by names the change): the comparator’s record is kept but never acted on — no credit, no revert (watch.onInconclusive included) — 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’s loop: settings — a person’s PR, an agent’s commit, another entity’s change to a shared file — stored as loop_cycles.confounded_by_edit. A member’s next window starts after the group’s last merge or revert (since, and ctx.groupChangedAt for repair’s last-n count). An Accept that loses the group race settles skipped naming the holder (under decide: auto at 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 its editablePaths is 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 its editablePaths. 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_accept grant 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 a human config, where autonomy.decide: auto is 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 merge gate 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
  • Loop events — the whole lifecycle, not just the analysis — loop:analysis.started / .suggested / .failed / .skipped from the analysis service, then loop:accepted / skipped / failed / applied / validating / validated / regressed / unvalidated / reverted / paused from 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’s streak / blind) — accepted, validating and reverted carry an auto flag telling the autonomous gate (config or grant, armed auto-merge) apart from a human, so a surface can tell human-gated from autonomous flow. accepted and skipped carry an actor BESIDE that flag, because the two answer different questions: auto says whether a person was asked, actor says 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
  • Runtime health of the loop itself — the dashboard’s /evolution loop 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’s lifecycle (live and stuck cycles) and an entity’s failureRows
    • 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_at older than one segment’s lease plus a 30-minute slack — the phase keeps throwing on every retry) and unrung (no lease, and wake_at passed more than 30 minutes ago — nothing is ringing cycles: the scheduler’s wake reconciler or pipelines is down). warn, not fail: neither is lost — a failing cycle is ended by the engine at leased_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: the cycle machine 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 failed settle, or a regressed one that shipped (merged_at IS NOT NULL — GitHub’s merge instant, recorded under every watch mode) and was never undone (no revert PR, or one closed / unmerged / past its deadline with no outcome — a revert still inside its wait is not listed), becomes a health row; a regressed the 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 with loop_ack_failure (loop:supervise) (loop_cycles.failure_acked_at, mirroring regression_acked_at, with who in failure_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-null error — 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 both error and 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
  • Per-cycle spend — loop_cycles.agent_ids links 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 a ctx.run is at-least-once), and the retention reaper exempts source = 'loop' so the rows it names — and the turn.completed events 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 /evolution and 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 Target prompt 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, and ScheduleSynchronizer (scheduler.enabled) converges BullMQ repeatables with the manifest; the synchronizer always runs so a disabled scheduler reaps stale repeatables instead of leaving zombies.

    • scheduler.enabled is 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.enabled and reconcile.schedule are 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.schedule takes only disabled / every N minutes / every N hours, refused at write otherwise
    • One timezone — every schedule resolves in the brain’s locale.timezone (omnislash.config.ts, default UTC); MANIFEST.yaml has no timezone key
    • Startup ordering — initial convergence (blueprints → schedules → heartbeats) completes before the health server binds, so Docker service_healthy gates workers on a first reap+sync
    • Content watcher in every mode, production included — a brain-sync tick 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, a manifest_update of scheduler / reconcile — the MANIFEST brake toggles) queues one deduplicated reconcile job on scheduler-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) — edits constitution/MANIFEST.yaml by 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 touching scheduler, reconcile or brief (its schedule is a clock the scheduler registers) queues the reconcile above (consequences.scheduleReconcile). Refused for a section another tool owns under its own gate: loop / evaluation / chat are loop_set’s (its widening check sends autonomy, perimeter and the brain-wide switch to loop_widen), budget is budget_update’s (budget_widen to raise or remove a limit). The dashboard’s brake toggles and Memory Settings drawer call the same op
    • manifest_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 the sha manifest_update takes. The dashboard’s Brain-wide card (/features) reads it for the health windows and the hook defaults
    • manifest_set_retention (governance:write — restricted, approval by default) — sets audit.retentionDays (the action log) or usage.retentionDays (the spend ledger), 0 = forever; a shorter window deletes the older rows on the next reaper pass, so it is not manifest_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, ServiceRegistry heartbeats, the hitl-reconcile (60 s) and connection-refresh (600 s) job schedulers, heartbeat retention sweep (24 h, 30-day cutoff), and stale heartbeat-run pruning
    • Modes — omnislash scheduler (long-running) and omnislash scheduler --once (single convergence then stop)
  • Auto-planning from scenario changes — the reconcile tick hashes manifest + scenarios + .blueprint.yml files, skips unchanged and recently-modified sources, and enqueues auto-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 lastHash unset so the next tick retries
    • Two-layer dedup — the BullMQ jobId plus the planning worker’s probe of the scenarioHash stamped into .blueprint.yml
    • Concurrency — a Redis distributed lock (omnislash:blueprint-reconcile-lock) across processes plus in-process SingleFlight coalescing
    • scheduler:scenario.skipped / scheduler:reconcile.completed / scheduler:reconcile.failed events, with reconcile state persisted at omnislash:reconcile-last (24 h TTL)
  • 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 legacy queue.add({repeat}) path.

    • Unreadable files are held, not reaped — DesiredState.unreadable is a required field, and only cause: '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 held in SyncResult; 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=false removes its own scheduler and throws UnrecoverableError (a manual heartbeat run is let through)
  • EntityRepairer — auto-repair malformed entity frontmatter — runs ahead of every reconcile (start, sync(), each content tick), rewrites repairable frontmatter on disk via atomicWriteFile so 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 — resolveSimpleSchedule is 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), with intervalToCron folding intervals into cron when they map cleanly. The scheduler reads a frontmatter schedule: through this grammar ALONE, so a string outside it never fires; scenario_update / heartbeat_update therefore 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 a pipeline-cron-{scenarioId} scheduler whose worker POSTs the Restate ingress with an idempotency key, emitting scheduler:pipeline.triggered; the Restate service name must equal the scenario id (validated at startup), and pipeline.targetKey supports keyed triggers.

  • Heartbeats — periodic agent prompts without workflow overhead — a *.heartbeat.md is reconciled into a BullMQ repeatable every 60 s (and on content change; none while scheduler.enabled is off), and each tick mints a fresh agents row grouped by hb:<heartbeatId> rather than a separate run-history table.

    • agent: frontmatter is an AgentSpec — role / modelRef / repo / systemPrompt / runIn, consumed at row creation; runIn: clone is 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 / sandbox checkout is a snapshot, and from the inside a stale one is indistinguishable from a fresh one: files are whole, the tree is clean, and git log answers about a window it only half covers. The ## Environment section 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; heartbeatId rides MCP_AUTH_SCALAR_PARAMS so 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 by issueDeclaredGrants before agent.call, because an approval card blocks the whole tick (the previous lever was a brain-wide "hitl": false)
    • maxStalledCount: 0 — await agent.call holds 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, createdBy at create; durationMs, exitCode, error, errorStack, errorCategory, provider, hasToolUse and toolFailures merged atomically afterwards, on the failure path too
    • toolFailures is 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.ts beside <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) and fence(text) (fetched text — a PR title — goes into vars through it). Asked after the brake and the switch, before the tick’s agent, grants or turn exist — a manual run too. false → the tick ends skipped (reason gate) with no agent row (no heartbeat: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’s gate: { 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. Doctor Heartbeat 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 with triggerSource='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’s path / sha; a file that does not load answers heartbeat: 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 keys name / 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 its grants:). 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 / evaluation are 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, and observe-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: skipped with its reason — brake (the scheduler brake), disabled (the heartbeat switched off), gate (its gate said no) — or failed — its definition missing or unparsable (errorClass), its gate failed (gate_error), a throw before the dispatch; a system-category event) plus the heartbeat: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 with offReason (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 the feature of its agent (agent → role → the feature shipping the role; a system role is engine); the engine passes (passes, feature engine: 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, and runNow: 'brief.send' on the brief; no pass writes an executions row). feature narrows everything to one feature (engine = the passes and the system agents’ prompts and watches). A workflow scenario carries its .blueprint.yml metadata — 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) nextFireAt is the job scheduler’s own next (nextFireSource: 'bullmq'); a clock not registered is projected from its file ('projected') and reads registered: false — firing per its file but not registered is a schedule declared but not registered, which a reconcile fixes; a switched-off row (offReason set) never shows a next fire, and one whose clock is still registered counts in summary.registeredOff (its ticks are dropped at fire time until a reconcile removes it)
    • schedule_forecast (schedule:read) — what fires in the next 6h / 24h / 48h / 7d (ahead, since 1.24.0 — was window), soonest first: every firing scenario cron and heartbeat (its registered clock’s queued next fire, then the ticks its schedule projects — source: 'queued' | 'computed', approximate for an interval no cron expresses; a queued fire already past — the scheduler down or behind — is one overdue: true row, 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 over 7d) is named in clipped with its total. feature narrows it like the overview
    • schedule_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 feeds
    • schedule_reconcile (schedule:write) — converge now (see Reconcile on request above); answers joined: true when a request was already waiting
    • scenario_update (feature:write) — a scenario’s schedule / enabled are scenario keys: a typed schedule the 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 through agent.send and replaying the scheduling turn’s auth at fire time.

    • A row and a timer — each prompt is a scheduled_prompts row (sp_…: scheduled → fired once delivered — the row’s fired and 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 stays scheduled, 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=cancel aborts it; schedule_cancel_prompt (schedule:write, recorded in the action log) cancels a pending one by its sp_… 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 failed when delivery gives up
  • Dispatch provenance in chat (TurnDispatch) — a turn nobody typed (a fired schedule_create_prompt, a heartbeat’s notification_send) is headed with what woke the agent: ⏰ **Scheduled** · \wake-nag` — /🫀 Heartbeat · `daily-challenge“

    • Derived, never stored twice — turnDispatchOf reads the ids AuthContext already carries, scheduledPromptId (stamped by schedulePromptWorker on the fired turn only) and heartbeatId; the schedule wins when a beat scheduled the prompt
    • One rule on both row-reading paths — the origin chat’s settleChatDelivery and the CrossSurfaceNotifier mirror share dispatchProvenance.ts: the turn’s user_message row is a note the agent wrote itself, so it heads the first message instead of posting as a 💬 human quote
    • Two carriers — turn.completed for the answer path, the ui-notifications job for the notify path, which stamps provenance only when the destination is not the calling turn’s own surface
    • Rendering — renderDispatchHeader in the body’s own dialect (markdown / HTML / plain), on the turn’s first message only, and on the ⚠️ bubble of the last failed turn
  • 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 }: engine news / 24 h → MANIFEST notify: → a scenario’s, heartbeat’s or hook’s own notify: (manifest_update, *_update); all shows passes and clean runs too, failures only what broke, none nothing
    • Repeats fold — the same thing in the same state edits its earlier message (↻ ×N since …, no push) until remindAfterHours; a recovery marks it ✅ Resolved; a new state is a new message
    • What is still open — notice_list (notice.list, runtime:read), and brief_digest’s openNotices
    • 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, so notice_list and the brief leave it out
  • 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:MM in the brain’s locale.timezone (engine default 07:45) or disabled. One keyed BullMQ job scheduler, brief-tick, registered by the scheduler and re-converged on every content change; the worker consumes it. Not gated by scheduler.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 or null = the default — Home’s brief settings write it); the chief’s persona sets the voice. The note carries no facts: the chief reads them with brief_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.total counts 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 behind usage:read, and the note points the chief at usage_summary over the same window, spelled as wall clock from / to in the brain’s timezone — a chief denied usage:read writes 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’s dispatch.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 (/compact or 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_send as its caller
    • Did it go out? — brief_status (brief.status, runtime:read; Home’s Daily brief panel — the instructions it writes from are manifest_get’s brief.instructions): today’s brief as written (since local midnight), the brief being written now, the clock, and quietSince once 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 (a policy.chat() naming another role there is followed, as the bot follows it); the doctor’s Daily brief check says why (brief_send refuses with the same reason)
  • 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’s grants: block instead; the schedule_cron HITL 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.md anywhere inside a feature directory — a hook says “on this event, matching this filter, run this prompt as this agent”; frontmatter carries event, optional filter, agent (bare role string or full AgentSpec), enabled and an optional grants: block, with the file body as the prompt template.

    • Template rendering — fillTemplate interpolates the whole event payload plus eventName and hookName into the prompt; a payload field the event’s schema marks as outside content (the mtproto:message.* text, sender name, sender record, file name, contact, buttons) (and the github:* titles, bodies, comments, branch/label names and check output) renders fenced by wrapExternalContent, never raw — an object field fenced whole, as the JSON the template prints
    • Filter matching — matchesFilter over event-body keys with array-contains semantics (a tags: '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.md can declare hooks: [...] in frontmatter (InlineHookFrontmatterSchema = the hook frontmatter plus a required id: slug (no .) — the hook’s id is <scenarioId>.<id>, unique within the scenario, and doctor Hook ids fails on a standalone hook of the same id — and an explicit promptTemplate, minus gate:, chatLaunches: and agent.scope: chat, which it refuses); an inline hook hears only its own run’s events, so doctor Hook events warns one on an event that names no run (mtproto:*, for one)
  • Gates — the code half of a hook or a heartbeat — ONE primitive with two kinds (@omnislash-ai/config/gate): a <name>.hook.ts beside a hook decides per fire, a <name>.heartbeat.ts beside a heartbeat per tick (§ Scheduler & Heartbeats); both answer the same verdict under the same timeout seam (hooks.gate / heartbeats.gate in MANIFEST, gate: in the entity’s frontmatter), never fail open, and a no ends the run skipped with run status reason gate — told apart from any other skip in run_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, with event typed by that event’s schema and ctx carrying the hook (id, name, feature, event), an AbortSignal, a logger, fence(text) — which fences counterpart text the gate puts into vars (a var renders exactly as returned) — and getServiceToken(providerId), a brain-declared OAuth provider’s live token (a heartbeat’s gate has the same fence and getServiceToken).

    • Verdict — false ends the fire with no agent (hook:skipped, not hook:failed; the run skipped · gate); true runs 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’s reach:, else its directory and above — handed to the gate as ctx.reach), and { entity } keys the reach’s one keyed context (alone or beside vars / 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-trigger job — after filter matched, 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 trigger goes 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 — timeoutMs 30 s by default, hooks.gate.timeoutMs in constitution/MANIFEST.yaml brain-wide, gate: { timeoutMs } in the hook’s frontmatter per hook; on expiry the fire fails and ctx.signal aborts
    • 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 in features/) fails boot like any entity file
    • Doctor Hook gates — fails on a gate that does not load, warns on a gate with no .hook.md beside 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:
  • 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-trigger jobs keyed by hookTriggerJobId(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 for contentProbe.hook, brain-tunable), and a lazy byEvent index 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 runId via createScopedHookRegistry, 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 pipelines runs single-replica
  • Per-chat launch limit — chatLaunches — a scope: fire hook on an event that names a chat launches a fresh agent per fire, so a busy group would launch one per message; at most perMinute fires per (hook, chat) per minute launch (engine 6, hooks.chatLaunches.perMinute in constitution/MANIFEST.yaml, chatLaunches: { perMinute } in the hook’s frontmatter), the rest end as hook:skipped (reason: 'rate'). Counted in the worker’s Redis rate limiter, across replicas; a scope: chat hook 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> and source: HOOK, so the dashboard renders per-hook run strips off plain agents rows the same way heartbeats do; runIn must be declared in frontmatter because a hand-set run_in cannot 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 the mtproto:message.* family); otherwise the hook is skipped at load with the reason, which omnislash doctor lists
    • 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 spent hook: fire), created with the hook’s AgentSpec on 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 subjectId tg:user:<holder> (the account’s own Telegram user) and auth.mtprotoChat, which every telegram_* 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 ⚠️ Hook notice
  • Split delivery ownership on a hook fire — the agent owns its report (it calls notification_send itself, so a skill shared across heartbeat/schedule/chat never double-posts), and the engine owns the outcome: a clean success is silent, while failed / cancelled / interrupted posts a compact ⚠️ Hook "<name>" — agent <name> <status> notice with the error and the last partial assistant output. awaiting_input is 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 by issueDeclaredGrants before the prompt is handed over, under the auth an approve in the fire’s turn would mint from: (hook, <id>) for the default agent.scope: fire (hookId stamped on the fire — one standing row covering every fire, maxCalls a per-refresh budget like a heartbeat’s), (agent, <chat agent>) for agent.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 — with durationMs for the gate and dispatch) and hook: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; a system-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’s path / sha), hook_read_file (feature:read, since 1.23.0: a standalone hook’s whole .hook.md as written with its sha; an inline hook has no file of its own), hook_update (feature:write, typed keys name / enabled / event / agent / promptTemplate — an inline hook’s write lands in its item of the scenario’s hooks:; 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 its grants:). hook_start (run:execute) is the Test-fire: dryRun: true runs 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 / onShutdown run 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 .md files (per line: <line>/MEMORY.md + <line>/YYYY/MM/YYYY-MM-DD.md daily 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.ts owns 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: migrateMemoryScopeLayout moves a pre-line workspace root (MEMORY.md, dated YYYY/, loose *.md — never the index) into the default line main/, then migrateDailyLayout folds flat daily logs into YYYY/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
  • 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 as write in AgentMemoryConfigSchema, 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), so main/MEMORY.md or ../main/MEMORY.md from research is 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-line memory/YYYY/ layout
    • Write own line, read by policy, in two strengths — recall (default [], '*' = all LINES) widens the every-turn recall sweep, read widens memory_search / memory_read alone, and recall implies read; two named resolutions (resolveRecallScopes / resolveReachableScopes, both feeding isPathReadable) filter candidates before ranking so maxResults is 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 — write is a single line by type; what an agent may do elsewhere is the narrower report below
    • 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 — priority with a foreign line is refused before enqueue, memory_edit / memory_delete stay home-only — and the worker signs the entry AFTER dedup with a trailing — <role> · <heartbeatId>, carried as origin on the write job and on memory:written. Meant for the SPEC tier (a heartbeat’s / hook’s agent.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 doctor Agent memory warns there. Never inherited by the flush curator or an agent_task sub-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 bad read)
    • The set rides the context, the argument selects within it — the line an agent curates rides ToolContext.memoryScope and the lines it may deposit into ride ToolContext.memoryWriteScopes (CLI agents via the MCP URL traits, API agents via AgentConfigBase), memory_write({ line }) must name a member of that set or is refused with the allowlist (writeTarget, the memory.* ops’ gate), and MemoryWriteJobDataSchema requires scope with 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.memoryScope is a per-dispatch tier above the role, applied in resolveLazyRoleConfig: the post-run flush curator and an agent_task sub-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 / recall take a declared memory.sources entry or a path inside one (content, people/maria, handbook.md) beside a line name, matched by whole segments (people/maria never opens people/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 — auditMemoryReach runs at worker boot and behind omnislash doctor’s Memory 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.overview enumerates the first-level directories (a line exists because something wrote into it — no registry, no CRUD)
  • 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.sources is physically unaddressable by memory_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_read treat them exactly like managed files, once a role (or constitution/MANIFEST.yaml) puts the declared entry, or a path inside it, in read / 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 in parseDailyLogDate (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
  • 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 at minScore (recallScore for 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-vec vec0 KNN when the extension loads (embedding MATCH ? AND k = ? — the only indexed plan vec0 has — against a table declaring distance_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 «о встрече» and report finds reports; 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_KEY the 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 same minScore, so a keyword match alone stays under the default bar (chit-chat cannot ride every turn while the embedding API is down) and surfaces through memory_search’s belowThreshold lever
    • Search by filename/path — searchPath matches 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 past minScore, decay and MMR; a path hit carries no score field at all — MemorySearchResult is a discriminated union on matchedBy so every consumer must branch
  • Context delivery — four parts on three schedules — MemoryFactory.buildMemoryContext returns priority, periods, recent and recall separately, and volatility decides placement: MEMORY.md and 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 same core is 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 Memory block, best-effort, clamped to the embedding model’s input limit in estimated tokens, and deduplicated against the agent’s own line’s MEMORY.md with excluded hits freeing their slot rather than shrinking the set
    • Per-agent memory config — four tiers (engine defaults → MANIFEST.yaml memory: → the role’s own top-level memory: block → agent.memory on 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 at 0, and write alone takes a fourth per-dispatch tier
    • The block merges one level — resolveMemoryConfig replaces 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 a memory: 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. resolveMemoryConfig returns null for it, and the wire carries memoryEnabled: false + memoryWriteScopes: []. promptFlags.memory is gone (a stale key is stripped; doctor Role declarations names it and lists every role that runs without memory). A top-level memory: false is 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
  • Write path — serialized through a queue, deterministic on MEMORY.md — memory_write enqueues onto the memory-ops queue, 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 explicit UNCONFIRMED when 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 95s vs never 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: an UNCONFIRMED job may still land, so it tells the agent to re-read with memory_read rather than to assume — reported as a tool failure, because every memory op is safe to repeat (write meets the dedup, edit finds no oldString if the first landed, delete is idempotent) and the one thing the agent must not do is proceed as if the write were done. Before this, all three arrived as success: "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’s failedReason. memory_edit and memory_delete share 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 delayed and emits no failure event, so a waiter never sees the first rejection: it waits out its whole window and answers UNCONFIRMED for 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 agent write ahead 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) enqueues op: '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: true additionally appends a list entry under today’s ## Memory (YYYY-MM-DD) engine-appended section
    • LLM deduplication of the daily-log half — deduplicateFileWriteContent is 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 (strategies new-file / llm-keep / llm-noop / fallback-append / fallback-large); one that adds nothing is dropped and the agent is told No 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 plain Written. 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 at memory.lines.<line>.fileMaxChars, since one number cannot serve a 50k curated line and a list of gotchas) binds memory_write, memory_edit and the dashboard’s whole-file save alike, in two steps. First compactMemoryForBudget drops 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-refused emitted, and the numbers named on the caller’s own channel (tool result / thrown error / a structured over-budget result 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 for MEMORY.md is the reader’s memory.priorityChars, which bounds the <agent-memory> block the same way recentChars bounds 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 lowered fileMaxChars, can; the check names every line whose MEMORY.md is 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, a memory_read of a MEMORY.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 needs runtime: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, and memory:compacted names the line it happened to plus the sizes either side of THE DROP — so beforeChars - afterChars is 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 — makes memory_write default to priority when the caller omits it
  • memory_search answers with evidence, not just rows — the result is {hits, degraded, belowThreshold, nearestBelow, truncated, minScore}, the same idea db_query applies (every statement’s rows carry a truncated beside 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 empty hits is ABSENCE when degraded is empty; a non-empty degraded (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. belowThreshold and truncated are not caveats but LEVERS for a caller who can pull them: material exists under the bar (search again at nearestBelow), or more cleared the bar than maxResults had 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. A note is 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 CHECKED block, 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 to recallChars ahead of the hits and is never the thing that gets dropped; recallChars: 0 switches 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 and MEMORY.md against 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 } — budget with its reading in words on a MEMORY.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 / source narrow it), memory_write (memory:write), memory_edit (memory:edit, exact-string replace that fails loud unless oldString is unique), memory_write_file (memory:edit, the whole file under expectedModifiedAt), memory_move (memory:edit, a new path inside the agent’s own line; never a line’s MEMORY.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 are alwaysLoad, and every memory tool is offered only to an agent whose role declares a memory: 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 — ensureFreshDbHandle compares 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 skipping openai steps 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 into index-sync-failed. It is also the record doctor_check → Memory embeddings and 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 watchDebounceMs 1.5 s debounce coalescing add / change / unlink into 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
  • 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 below week), and the latest of each level is carried into the agent’s context as <agent-memory-periods>. Off until the brain names lines in memory.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:consolidator turn 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_delete plus 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 maxRunsPerTick a 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 → the role-consolidator.md frontmatter (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 the memory-ops writePeriod op 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 periodChars across a full ladder (periodSummaryTarget, no new knob; over it is filed as written and reported as overTarget), 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.md stays 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 per memory_search, and a memory:consolidated event instead of silence
    • The event reports the WRITER’s verdict — the tick awaits the memory-ops job, so wrote names a file that exists, skipped says why nothing was written (a newer summary already owns the path, or the sources vanished) and failed leaves the period due for the next tick. Three shapes, one per outcome, so “present only on wrote” is a type rather than a comment. dropped on wrote is a set difference — every source the summary does not cover, whether it was over budget or gone by the time it was read
  • 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.md and 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 toggle memory.flush.enabled, per-role memoryFlush frontmatter — and only for a role that declares memory: (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 an agent:memory_flushed event, so a curator that has quietly stopped saving anything is alertable as a streak of nothing_saved per 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 answers done cannot read as productive; bareToolName normalizes the CLI’s mcp__omnislash__* spelling so the verdict is harness-independent
  • 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.sources and memory.languages (same rebuild, embeddings served from the cache) in omnislash.config.ts; OPENAI_API_KEY, DEBUG_MEMORY, DEBUG_MEMORY_EMBEDDINGS env; events memory:written, memory:edited, memory:deleted (each naming the line it touched and its size in chars, the unit fileMaxChars counts 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 declares state: { namespace: nova } (and must hold db:read + db:write, or '*' — the role load fails otherwise) and any VIEW in public named nova_* is rendered into <agent-state> on every turn; the agent creates and evolves the tables underneath with db_execute DDL, inside a turn, with no deploy and no migration. Small → straight into context, large → a table (tables never render) plus db_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_description as the subtitle) answers what the block contains, so a CREATE VIEW adds one and a DROP VIEW removes it with nothing else to update. Tables are never rendered; materialized views are out of scope until a refresh story exists (db_schema lists 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.md never rides along. as_of rides 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 public named <namespace>_*, tables and views alike, is the namespace’s to create, alter and drop (the prompt renders the db_schema pattern that lists it, every _ escaped — sec\_ops\_% for sec_ops — from the same namespaceLikePattern the render’s catalog scan uses), that nothing else is its to ALTER or DROP (rows it still writes where its instructions say so), and never to CASCADE, so Postgres itself refuses to drop what a view still reads. A rule checkable from the name at any moment, replacing “never DROP/ALTER what you did not create”, which rested on history a fresh session does not have. Prompt-level only; the hard gate is db:write at hitl: 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 an UPDATE flips the hash for nothing) and now() / clock_timestamp() / random() never appear in a select list (time belongs in a WHERE, so the view changes when the fact changes)
    • Three budget tiers, 0 on any of them = the block off — StateConfigSchema .default()s (views 8, rows 20, chars 4000) → MANIFEST.yaml § state (numbers only — a brain-wide namespace is a collision by construction and fails the load) → the role’s state: block, one-level merge through resolveStateConfig, a heartbeat inheriting its agent: role’s block. The gate is the declared namespace, 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_query past rows (one row is read past the budget, so “at least one more” is the only fact a second COUNT(*) per view per turn would buy), trailing views dropped WHOLE at chars and named, a nameless … +1 or more views not shown past views, 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 the chars budget the healthy views need; db_query on the same view still answers with DETAIL / HINT) — one transaction per view, in parallel, so a single failure neither aborts the others nor turns worst-case turn latency into views × statement_timeout
    • Per-agent scoping without a second declaration — every state transaction (the render, and db_query / db_execute when 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 on omnislash.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 raw current_setting('omnislash.agent_id', true): that one reads '' instead of NULL on any pooled connection that has served an agent
    • /context reports each body separately — a role with state: 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
  • 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. The feature-contexts engine skill carries the decision and the procedure. Full model: contexts.md.

    • Closed when vocabulary — run (a run whose reach holds the context — a heartbeat tick, hook fire, scenario task or pipeline runAgent, 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’s reach: 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 } or runAgent(ctx, { reach }); resolveRunReach, decision 0130
    • Role allowlist contexts: — ['*'], names, or nothing = none (fail-closed, like skills:); run and an access.yaml chat binding bypass it (explicit links: the feature tied itself to the role through its entity’s agent:, the group rule tied the chat to the context); the inferred links (reply, ask, code, a context_bind binding) do not
    • Brain code links (code) — <id>.context.ts beside the .context.md (defineContext({ match, render }): match(turn) → { entity? } | null links the turn, render(entity) → [{ key, text }] adds sections after the views within chars), attach: [{ context, entity? }] in the engage() verdict, and { entity } / { reach } in a hook gate’s verdict (the fire’s run link carries them — as does a pipeline step’s runAgent({ entity }) / ({ reach }), TerminalError without a feature run); inferred (allowlist, pin, keep), fail closed under contexts.codeTimeoutMs (2000) with a logged reason; doctor Feature context code
    • Catalog + context_open — admitted ask contexts 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 for keep turns; a role without context:read gets no catalog (doctor warns)
    • Turn-tail placement — attached contexts ride the context envelope as <feature-context> blocks (sections instructions + one per view), hashed into agents.context_marks per section; CLI re-sends only what changed, API renders each turn
    • Claimed views leave <agent-state> — a view in any context’s data: is excluded from every role’s state block and its views budget; it rides only with its context
    • Budgets, never silent — FeatureContextConfigSchema (chars 4000, keep 3, turnChars 12000, maxActive 4) → MANIFEST contexts: → the file’s chars / keep (contextConfigFor); views are cut by rows, then stubbed; a context whose instructions are over its chars, 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.md switches a feature’s contexts with it; contentProbe.context sets 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’s requestApproval / awaitApproval included), and any send with about: { context, entity? } or about: { entity } (keys the one keyed context of the sender’s own run) — notification_send, file_send, voice_send, context.notify, a pipeline’s notify() / requestApproval / awaitApproval. A run’s reach rides every origin the run writes (its sends, its answer, its cards, its hook notice); a run whose reply reach entries are all keyed and carry no key is refused a send without about, 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 an about naming no context. Kept for MANIFEST messageOrigins.retentionDays (365, 0 = forever)
    • chat — a chat or thread bound to an entity — statically in a group rule’s contexts: { <context>: <entity> } (constitution/access.yaml; a widening edit), or at run time with context_bind({ context, entity?, chat?, thread?, bot? }) / context_unbind (context:bind, moderate; omnislash.context_bindings, re-keyed with a supergroup upgrade) and the pipeline SDK’s bindContext / unbindContext; every turn there carries the context with that entity’s key; a binding may not contradict the file’s; access_explain lists a chat’s bindings; doctor Chat access warns on a binding naming no context or a mismatched entity key
    • The pin — tenant isolation — a chat bound to C@e attaches C only with key e from any link (run, chat, reply, ask, a held open): another key is refused and logged, none takes e; context_open with another key is refused with the reason, without one it opens for e
    • 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 its reply contexts, each with its entity key — the run’s reach, or the one context the send was about (the reply sees what the run saw); the role must admit them; the link stays held on the agent for the context’s keep turns after the reply (counted off the spend ledger)

Skills System

  • Agent Skills standard, with omnislash extensions — a skill is a directory with SKILL.md (frontmatter + instructions) and optional scripts/, 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) and description (the primary trigger the agent routes on)
    • metadata.omnislash extensions — capabilities (informational), requires.env / requires.bins / requires.anyBins / requires.os (closed world SKILL_PLATFORMS, fail-open on an unknown name), install[] specs, defaultEnabled, listed, and the computed depsSize
    • listed: false — a specialist skill — never in a skills: ['*'] catalog; a role that names it in skills: still lists it, and an engine module may point at it; eligibility, activation, skill_get and the dashboard (badge “on demand”) are unaffected, and it is never counted as dropped
    • Top-level enabled: is not read — the only authoring lever is metadata.omnislash.defaultEnabled (omitted = true), always beaten by an explicit skills/.settings.json entry
  • 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 ## Skills heading, ONE load sentence for the whole catalog (CLI: read <root>/<entry>/SKILL.md; API: call skill_get with 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 when name ≠ directory) and its name on 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’s skills: or agent_task’s skills replaces it for that run. An API agent’s catalog is sent only with skill:read (or '*'), the capability that opens a skill; skill_get still opens a skill the role does not list by name, as long as it is enabled and eligible
  • Two delivery modes — CLI agents get the catalog under the absolute skills root and read <root>/<entry>/SKILL.md with their native Read tool (no symlinks, no writes into anyone’s working tree, identical across harnesses); API agents get the same catalog plus skill_get / skill_read_resource tools.

    • promptFlags.skills — one value, three states — 'engine' (default: engine catalog on, harness Skill tool left out of the built-in allowlist), 'harness', 'both', delivering what the skills: 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 declared requires.env values into the agent’s skillEnv, 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 any requires.bins, or missing all requires.anyBins; excluded skills vanish from the catalog, from skill_get / skill_read_resource and from MCP resources (the load path checks the same predicate the catalog lists by) while loadAll() still returns them for the dashboard.

    • “Enabled but inactive” diagnosis — explainEligibility classifies the cause into missingEnv (set env + restart), rebuildableBins (covered by a bakeable apt/uv/node spec → omnislash build + redeploy) and unbakeableBins (add a bakeable spec first), surfaced identically in the dashboard badge, a worker-startup warning per skill, and the Skills doctor check
    • Off-platform is not a defect — a skill excluded by requires.os reports 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
  • 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 frontmatter name, 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, so skills update never touches it and check never 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, the engine/SKILL.md resource on API — so both modes can reach it
    • Reserved name engine — a brain that hand-created skills/engine/SKILL.md before 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.md at 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 (a system: role has no feature, so its '*' carries none); a role elsewhere that names it in skills: gets it, as with listed: 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 names fails on it (only skills/<name> over skills/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
  • 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’s skills/engine/ on omnislash 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 in FEATURE.md, the hand-off to a feature lead — with one references/ file per step), chat-access (who may use a bot, data vs code, the access_* 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.md contract, picking the link, the role’s contexts: 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 the loop_* tools and the rails around the human gates, with the authoring depth in references/), 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.yml pair, the page SDK, deliver: turn vs events to code, and checking it with doctor), whisper-ctranslate2 (defaultEnabled: false, the first with an install: spec), yt-dlp (defaultEnabled: false, the fetch hop the transcription chain never had — a video page URL is HTML, so web_fetch and audio_transcribe({url}) both come back empty-handed), instagram (defaultEnabled: false; the first engine skill with a bundled Python runtime — scripts/instagram.py under uv run with 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 browser sessionid cookie and is spent as a browser — replaying it through the private mobile API is what gets it invalidated. INSTAGRAM_ACCOUNTS is 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 a system:browser agent that follows the skill’s own login-brief — log in, pull the sessionid cookie with browser_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/ (lifecycle state: never swept, hidden from the artifact browser); uv’s cache is the uv_cache volume on pipelines, bound RW into the bwrap jail, which is what lets any uv run skill 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 offers fork
    • 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.override sentinel (brain-owned, deliberately outside the engine-swept directory) stops the sync entirely
    • Admission rule — a fact that fits in a tool description belongs 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
  • Skill store CLI — omnislash skills add | install | update | remove | check | list | enable | disable | fork | measure-size; add/install/update/remove/check operate on the brain root and the lockfile only, while list/enable/disable/fork see both roots.

    • The same store as agent tools (since 1.24.0) — skill_read_file (skill:read: SKILL.md with its sha and the frontmatter split for reading), and the writes under skill:write (moderate, no approval): skill_write_file (SKILL.md whole, compare-and-swap on its sha; an engine skill or a frontmatter name: 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_install is skill: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 no skill_get op: skill_get stays the runtime loader
    • Lockfile (skills/.lockfile.json) — resolved 40-char SHA (or "local") plus a filesystem-metadata-independent canonical integrity hash; 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 bare owner/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 — check reports missing and modified (including integrity: null from a crashed add) and exits 1 for CI; install reconciles, update re-resolves the pin via git ls-remote and reinstalls only on a moved SHA
    • Crash recovery — the lockfile entry is written before the directory sync with integrity: null as a pending marker, so a mid-flight death is repaired by the next install rather than being mistaken for a hand-curated directory
    • Safety — 120 s network timeouts, GH_TOKEN/GITHUB_TOKEN for private repos with embedded tokens redacted from errors, and skill names validated as a single safe path segment
  • 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 own defaultEnabled (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 disable never took the skill’s RUN uv tool install back out. A committed file is readable at build time and run time alike, resolved identically (entry → defaultEnabled → on)
    • The bake gates on it — disable sheds the binaries, enable brings 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 state explainEligibility already reported as rebuildableBins
    • First heavy opt-in corpus skill — whisper-ctranslate2 — local speech-to-text, shipped defaultEnabled: false: every brain gets the text for free, and only one that writes {"whisper-ctranslate2": true} and rebuilds pays for uv tool install. Before the gate, an engine skill carrying an install: spec taxed everyone, so it could not be shipped at all
    • Activation and Dockerfile move together — skills enable|disable in the brain repo regenerates the Dockerfile itself; run against prod it says the binaries arrive after omnislash regen + commit + deploy, and omnislash doctor reports 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 frontmatter name; every writer resolves through SkillProvider.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 — add writes no override and drops an orphaned entry so a reinstall starts from the author’s default; remove drops the entry (enabled ⊆ installed skills); install never touches entries, so explicit overrides survive a clone
  • 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/bin via ENV 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 -v loop runs as the runtime user and fails the build, catching package/bin mismatches; the checked list comes from collectBakeTargets (bin ↔ the spec that installs it), not from spec.bins, so an anyBins skill isn’t over-required
    • Cache cleared inside the same RUN — a separate rm -rf layer cannot remove bytes already committed (decisive for uv wheels)
    • Bake provisions the active set — it reads the same skills/.settings.json the 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 pass process.platform, so requires.os is 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 by SkillProvider.loadAll() on every call so the dashboard shows fresh numbers without a restart
    • omnislash install — installs missing skill dependencies locally
  • Skill resource security — skill_read_resource resolves the path, checks containment against the skill’s base directory, rejects symlink escape on every segment, and whitelists only files collected into resourceNames at 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 exposed omnislash://skill/* MCP resources, so a SKILL.md edit applies without a restart; in production skills/ is part of the engine G-set baseline, lives in the brain_state volume 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: g then a nav item’s letter opens it (g h Home, g i Inbox, g r Runs)
    • Display zone — dates render in the brain’s locale.timezone with the zone’s short name when it is not the device’s; ?tz=utc|local overrides it for the tab
    • Brain-branded chrome — sidebar logo and browser favicon are served from the running brain’s assets/logo.* / assets/favicon.* at /logo and /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 the config.get op (part: 'project')
    • Attachment upload/serve — POST /upload (25 MB cap, sanitised filename) persists into the content-addressed blob store and returns a blobRef; GET /attachment?ref= streams it back through BlobStorageRouter.retrieve() with a path-escape guard
    • Own health surface — /health, /ready, /metrics on the dashboard’s own port; registers itself in the Redis ServiceRegistry with heartbeats
    • Statics survive a deploy — the document is no-cache whichever handler produced it, hashed /assets/* are immutable on 2xx only, a missing chunk 404s rather than getting index.html back, and a client-side vite:preloadError triggers one guarded reload behind a Suspense error boundary
    • Access control — Caddy basic_auth on app.{PUBLIC_DOMAIN} in production domain mode, 127.0.0.1-only port binding in start mode; there is no per-user identity inside the dashboard yet, and dashboard-originated writes attribute to the dashboard Actor kind
  • 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.list and every agent.* read and command — the agent router 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 over httpSubscriptionLink (SSE), everything else over httpBatchStreamLink — 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
  • 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), and unavailable with the reason when the brain has no such chat (no bot runs the chief’s role, no primary owner, no token); never creates the row
    • chat.send (chat:send) — the caller’s message as a user turn of that chat (queued behind a turn in progress, or interrupt), 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’s local:// refs, checked by the same ingress check as every chat composer (@omnislash-ai/config/chat-attachments). A slash command or /compact is not a message — it goes through the agent’s own routes on chat.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> when DASHBOARD_PUBLIC_URL is set, the ref as code otherwise
    • chat:send — a capability of its own (moderate, HITL): every op is also an agent and MCP tool, and chat_send puts 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": false in constitution/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, null without DASHBOARD_PUBLIC_URL; the path alone is refGoPath, @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_status links 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 /go forwards 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) / subViewGoPath beside the two above. A view rather than an object is a query ref: 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 URL endpoint_list reports) 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 as runs or inbox, 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.onOutputMany for side-by-side sessions and activity.onEvent for 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 by terminal.onData and the agent streams)
    • POLL tiers — what no topic announces polls on one constant set (live 5s / normal 30s / minute / slow 5min) 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 asking health_list, run_summary, hitl_list, loop_status or brief_status hears. 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_list with 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, /agents or /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=7d reaches 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 plus contains / runs-as / uses / reads edges; a failing collector marks only its group error, and every attention / error carries 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/view turns 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 name its FEATURE.md declares, 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-as role, a role’s reads memory line, a uses skill — 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
  • 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.emit also appends to one capped Redis Stream (@omnislash-ai/events/tail, ~20 000 entries); the event.tail op pages it (readEventTail, also the agent tool event_tail) and activity.onEvent follows 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 most scan entries (default 2 000) and returns next to 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
  • 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 buildLogQL over 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 at POLL.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, keepPreviousData so 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 dev the page short-circuits to a notice instead of querying an unreachable backend
  • 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_list topLevel: 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 a query: 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 / k and Esc walk the list it was opened from, p / o open 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 whole run_get answer 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
  • 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’s counts) —, 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 a query:agent.list ref
    • 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 engine whose content is its sessions, and an Engine Contents group
  • 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 (engine for a system: 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’s reapAt); 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_get whole
    • 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”
  • 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, the runIn rung); 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 through agent.update / agent.widen, whose input IS the engine’s AgentPatchSchema and whose validation IS resolveAgentPatch; the chat bot’s ✏️ Edit and the agent_update tool 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
  • 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 own agent.update write (a bad ref meets the same refusal); a bare /model opens the picker (a host without one leaves /model in the field for a ref)
    • Harness passthrough that reaches the harness as a command — a passthrough (/review, /init, …) goes through agent.send_command, not agent.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’s refs (agent.send writes 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 the fake test 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)
  • 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’s intercepted, 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 over hitl_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 (only bulkable rows). A request opens beside the list (j / k walk, 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
  • Features (/features, since 1.23.0; views since 1.24.0) — every feature ordered by its weight from feature_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 — engine attention reads Degraded only when something is in trouble beyond a person owing an answer — health_list’s onlyNeedsYou), “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 through manifest_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
  • 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: and loop: 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
  • 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_summary with role), 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
  • 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.md inline 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.search greps 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 by models.json
    • Bots — /chats (the Settings block; tabs Bots · Identities · History): who may use which bot where. A header of bot health from getMe() (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 plus identities, tier inline-editable), Simulate (the real resolver and the brain’s real policy functions 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 and identities — with the tier that holds in every chat and where it is set; History (/chats/history) is access.yaml’s commits. The page binds the access.* ops: a save goes through access_update, and one that would widen access is listed and written through access_widen only 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 with access_overview — chat-access.md
    • Eval methods (Evolution › Methods, /evolution/methods) — brain-defined evaluation methods with an inline prompt + criteria editor
  • 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.plan dry run) loads the planner’s plan as the draft
    • Unsaved-work guard — useBlocker on a dirty draft plus beforeunload; 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.get reads the file and its sha, blueprint.write with yaml saves it (which locks the plan — the first edit asks) on that sha, blueprint.set_lock locks and unlocks
  • 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 a query:doctor.check ref, a chat’s “Who can talk here?” with its bot + chat refs. 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 beforeunload covers 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 role system:editor-assistant (role-editor-assistant.md; a brain overrides its prompt or its read-only grant by shipping its own copy); editor-chat.findAgent resumes the previous thread, sendMessage mints 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 the editor: 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 one MasterDetail component: a filterable list beside a pinned detail frame whose active tab owns its own scroll, selection in ?selectedId=/?detailTab=, one switched pane below md (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 write constitution/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_summary answers the same: its Prometheus sections null, the by-agent slice still there)
  • 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 previews budget.update and asks before a raise or removal (budget.widen), Raise for this window adds money to the current window only (budget.raise)
  • Audit › Cost (/audit/cost) — token and spend analytics with deep URL state (window incl. calendar-anchored today / yesterday / mtd vs rolling 24h, all, wall-clock from/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 as usage_* 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
  • 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 own compare), 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 repair count), 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 approval card (producer loop), so the story’s waiting chapter answers it through hitl.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 (entity filter) and follows one with agent_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 role entity 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.getConversationMessages by episodeId), 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 the loop.set op (loop.widen for 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 of constitution/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 (or loop_widen for a widening), keyed by the ids loop_policy lists. loop.* (incl. autonomy.requireCalibration, group, proposer.{pipeline,modelRef,instructions}, and MANIFEST’s groups.<id>), evaluation.* (enabled kill switch / opt-in as two halves, method, modelRef, requirements, gradeAfterHours, metricTolerance, scoreWeights, sample, judge.maxUngradedShare, human with agreement, context, and metrics.<id>.<field> per field) and a chat role’s chat.{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’s criterion, scoreWeights, context, chat.episode), slower or costlier grading (sample, gradeAfterHours, human.ask). Refused only where the schema has no tier, nothing reads it (lessons.retentionDays on an entity) or the metric spec refuses it (a catalog metric’s criterion, a criterion the manifest already answers, critical on 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; an evaluation: edit on an ungraded entity writes enabled: false beside 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.policy op brain-wide: the same knob views over an entity that wrote nothing (winning engine or brain), each with overrides (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, the chat: knobs, MANIFEST’s lock groups with their members, and the loop code (loopCode: the functions omnislash.config.ts loop: defines, the entities with their own <id>.loop.ts); edited through loop.set / loop.widen with 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’s false) — 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 functions omnislash.config.ts loop: defines and the entities overriding them with their own <id>.loop.ts. Every row opens the same editor as the entity page’s tokens (KnobEditor over 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’s chat: terms as the same rows, scoped to the entity (Only this entity / All entities / Reset); the scenario page’s raw evaluation: 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 active isolation, 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 under constitution/Loop/, each with who reads it (the same proposerBriefPath the cycle calls — proposer/idea for a cycle opened on an idea, else the preset’s, else the kind’s; implementer for every implementer turn), and for the Proposer the entities whose loop.proposer.instructions add 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_changed for cycles in flight and calibration restarting; the Proposer with a shell in the live brain; the Implementer with a shell and no isolation; a hitl: true capability under decide: auto failing missing_grant) — and refuses a role that would not load before the confirm. The write goes to the brain’s constitution/Agents/system/role-<id>.md; the first one materialises the whole engine template there (frontmatter with capabilities and promptFlags, 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 to constitution/Loop/proposer/<brief>.md / implementer.md only, 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 through instruction.list / instruction.get and their saves through instruction.write_file (loop/…); role saves are previewed with a role.widen dry run (Review) and committed through role.update, or through role.widen when they add to the grant — the person at the page decides, so no approval card — over the one role writer writeRoleFile (@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 page loop_status links always leads to the switch. The /system/scheduler tables 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 as proving; waiting when quiet, off with the dashed ring), its one-line status as the tooltip, no counts, and a Review → 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 raw loop: / 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)
  • 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 is artifact.update, making it public or renaming it is artifact.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, HealthChecker results (DB, Redis, queue), the Redis service registry with heartbeats, and a Prometheus metrics summary; Sync now runs brain.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 is dashboard.doctor.fixRole in constitution/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 Credentials doctor check counts; agents read the same inventory through credential_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
  • Web Terminal (System › Terminal, /system/terminal) — real PTY shell sessions from the browser (xterm.js over SSE, terminal.write / resize mutations, no WebSocket); ?service=<compose service> (local for 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 -it into 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 in omnislash 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_auth credentials are root credentials. omnislash compose --prod therefore refuses to build a domain-mode deployment (PUBLIC_DOMAIN set, dashboard enabled) unless ADMIN_USER and ADMIN_PASS_HASH are set. omnislash start publishes 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 / codex login 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 against visualViewport, since no browser shrinks the layout viewport for it) so the row stays reachable while typing
  • Env Var Catalog (System › Config, /system/config) — every environment variable the engine and brain recognise (merged via defineProject({ 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’s unset) 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/user category and an auto-generated example payload derived from the Zod schema
  • Webhook Endpoint Catalog (Connections › Inbound, /connections/inbound) — every POST /tool/:name and POST /event/:source endpoint 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_list answers each row’s auth (shared-secret, or source-defined where the source’s declared verify() REPLACES the WEBHOOK_SECRET check) and the note, Auth field, legend and curl all branch on it; the curl’s origin is endpoint_list’s http.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 is endpoint_get’s
  • 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’s git_request_app card 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 with lastSyncAt / 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 live GET /installation/repositories per 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.search op (the same answer an agent gets from brain_search) fans out ILIKE and 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.translate turns 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 / dark applied as a data-theme attribute written before first paint to avoid FOUC (a person may also pick system, which paints the device’s light or dark); components never know the theme
    • Multi-tenant branding — --color-brand / --color-brand-muted overridden 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 Dialog and side Sheet drags wider or narrower from its free edge (ModalResizeHandle, min 320 px, viewport-clamped), the width kept per resizeKey or size class in localStorage
    • 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(), no document.body portals, no ad-hoc placeholders, no query without its liveness, no server console, no import across a zone’s public surface)
  • Phone-First Shell — one chrome around every page, its navigation read off the routes’ typed staticData and 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 /atlas stage 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 an executions row 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, --assert for the smoke failures) and smoke, 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, whose README.md is the world bible), written through the engine’s own writers: deterministic prefixed ids from one shared contract (STUDIO in e2e/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 by stack --world studio (a copy of the brain without the edge feature, presentable env, the seed’s clock printed as SHOT_NOW) and shot by a declarative Playwright manifest whose every slot carries a requires gate, shot in three phases in light/mid × desktop/mobile; SHOT_NOW pins 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 marked retired)

Events System

  • Typed Event Infrastructure on Redis — two complementary abstractions in @omnislash-ai/events: EventBus (pub/sub, real-time, fire-and-forget, multi-subscriber) and StreamBus (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 into allEventSchemas; payload types auto-derive as EventPayload<'domain:event'>
    • workflow: (24) — run started/completed/failed/cancelled/paused/resumed, phase and task lifecycle, task progress and retry, the four recovery.* events, and seven eval.* events
    • pipeline: (18) — run started/completed/failed/cancelled/paused/resumed/killed/progress, step started/completed/failed/retried, step.agent_event (live nested agent progress), and five eval.* events
    • agent: (12) — created, started, completed, input_request, cleared, context_threshold, fallback, circuit_breaker, auth_issue, tool.executed, resumed, memory_flushed
    • planner: (8) + planning: (1) — session/stage lifecycle, clarification, cross-process cancel request, blueprint ready, and the materialized planning:session.snapshot
    • heartbeat: (6) — completed plus five heartbeat:eval.* events
    • scheduler: (6) — scenario skipped, remote command, reconcile completed/failed, pipeline and heartbeat triggers
    • memory: (7) — written, edited, deleted, compacted, consolidated, synced, budget-refused
    • loop: (19) — the four analysis.* 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 naming targetKind + targetId
    • queue: (3) — job started/completed/failed for every BullMQ queue
    • mtproto: (8) — account authorized/removed/default-changed, used for cross-process pool cache invalidation; the inbound message.received / message.edited / message.deleted / message.read and gap, 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 lifecycle
    • boss: · chat: · connection: · evaluation: · ingest: (1 each) — boss-run expiry, chat turn completion, OAuth refresh failure, the materialized evaluation:session.snapshot, and external event ingest
  • Subscription Surface — emit (non-throwing; failures go to onError) appends to the event log and publishes; read live with observe (awaits the Redis SUBSCRIBE confirmation), observeOnce (auto-unsubscribing), observePattern('domain:*') via PSUBSCRIBE with discriminated narrowing and observeAll — a single PSUBSCRIBE over every known event, which is what the dashboard activity collector uses; read durably with consume(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/consumeBatch with consumer groups, claim, pending, moveToDlq, forceRemoveConsumer
  • Stream Reliability — consumer-group recovery (idle messages auto-claimed via XAUTOCLAIM after claimStaleMs), a dead-letter queue at {stream}:dlq past maxDeliveries with 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) through onEventError instead 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, WildcardMatch and WildcardPayload derive the legal patterns and their payload unions from the schema map, so observePattern('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 or null on timeout, and always releases the subscription; used by the brain-sync:command → brain-sync:completed pair
  • Event Matching Utilities — isKnownEvent, matchesEvent (exact or prefix:*) and matchesFilter (exact payload-field match), exported for hooks, outbound dispatch and filtering
  • Singleton Wiring — initEvents(redis, options) builds the EventBus + StreamBus pair with every schema registered; mutex-serialized, idempotent for the same Redis, auto-closing on re-init with a different one. events() throws NOT_INITIALIZED if called first, and resetEvents() 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.run step in the Restate handler that already owns the entity
    • P2 — consume + keyed job — consume(group) on the event log, then a BullMQ jobId keyed 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
  • 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 via defineEventSource.extract → resources), and a watch subscribes ONE target to one key
    • watch_create / watch_list, and watch_delete (the watch.* ops) — an agent subscribes itself (capability event:watch, moderate; a PR URL or a key, normalized; a person names the agent with agentId): 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_get lists the agent’s watches too. watch_delete({ id }) (event:watch, recorded in the action log) stops one agent watch by its watch_… 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 (debounceMs after the newest event, never longer than maxWaitMs after 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 is NONE); all admits 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 by maxTtlHours; one final message tells the agent the silence is its to interpret — extend, remind a person, or close), or its delivery budget (maxDeliveries). watch_delete stops 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.watch in constitution/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 — PlanningChannel and EvalChannel reduce 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 RedisLeaderLease on reducer:leader elects 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 EvalChannel owner reconciles every still-running snapshot against the durable evaluations row, healing terminals that landed in the failover gap
    • Atomic start — the shared SnapshotChannel base attaches reducers through a collector, so a partial-subscribe failure detaches everything that did attach and leaves zero live reducers
  • Event Category Metadata — a schema may carry .meta({ category: 'system' | 'user' }); the dashboard’s event stream (/system/events) hides system events by default and the event catalogue reads the flag back through z.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 own examples, 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’s event: names and what a Test-fire runs on
  • Event Log — EventBus.emit pipelines an XADD … MAXLEN ~ into omnislash:events:tail with its pub/sub publish: the capped log the consume groups read (~50 h of a busy brain’s events) and the debug views page (readEventTail newest first, followEventTail live); never a system of record
  • Invalidation Topics — @omnislash-ai/events/topics maps 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-wide runs (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, so runs does not announce it); the dashboard’s changes.onTopics({ topics }) SSE adds the feature:<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) — one changes.onTopics stream per tab over the meta.topics of 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; useCommand stays pending until meta.invalidates has refetched
  • 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 / MediaUnderstandingOutput types, magic-byte detectMime({ buffer, headerMime, filePath }) with sniffed > extension > header precedence, mediaKindFromMime, normalizeMimeType, extensionForMime, isGifMedia, GIF/audio/image/video predicates, and 30+-entry EXT_BY_MIME / MIME_BY_EXT lookup tables
    • Size limits — maxBytesForKind (image 6 MB, audio 16 MB, video 16 MB, document 100 MB) plus the transcription-specific MIN_AUDIO_FILE_BYTES, DEFAULT_AUDIO_TIMEOUT_MS, DEFAULT_AUDIO_MAX_BYTES (20 MB network) and MAX_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)
  • Media-Understanding Provider Contract — an OpenClaw-shaped MediaUnderstandingProvider { id, capabilities?, transcribeAudio?, describeImage?, describeVideo? } with credential-free request types (apiKey / baseUrl / headers come 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 assertSafeUrl pre-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 TransformStream byte cap that errors before the body fully buffers, per-phase undici timeouts (headersTimeout / bodyTimeout / connect.timeout) and caller AbortSignal cancellation
    • Filename & type recovery — RFC 6266 / 5987 filename*=-aware Content-Disposition parsing, MIME detection via detectMime, automatic extension append
    • Typed failures — MediaFetchError with code: 'max_bytes' | 'http_error' | 'fetch_failed' so tool wrappers can branch into a structured failure(...) result
  • Image Operations (@omnislash-ai/media-processing) — sharp-only and lazily loaded so the dependency stays optional: getImageMetadata, normalizeExifOrientation, resizeToJpeg (mozjpeg), resizeToPng, convertHeicToJpeg, hasAlphaChannel, and optimizeImageToPng — 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 via pdfjs-dist on a worker thread and falls back to rendering pages as pixel-bounded base64 PNGs via @napi-rs/canvas when the extracted text is below minTextChars; if canvas is unavailable the result is text-only. It takes ownership of buffer: 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 via officeparser, tables kept as markdown tables and each spreadsheet tab under its own Sheet: <name> heading, on a worker thread like the PDF path, taking ownership of buffer the same way
  • Multi-provider audio transcription — a brain-declared chain — transcription.chain (the STT mirror of speech.chain) declares the order; MediaUnderstandingService walks it with automatic failover, defaulting to Groq whisper-large-v3-turbo → OpenAI gpt-4o-mini-transcribe → Deepgram nova-3 → local whisper. Since 1.24.0 a role’s transcription: block (hot, written by role_update) replaces the brain’s block whole for voice messages in the chats that role serves and its audio_transcribe, and a whisper step (groq, openai, local) takes a prompt — vocabulary or a mixed-language sample, the way to hear a speaker of two languages; transcription.language is accepted by the brain’s schema too.
    • Terminal step needs neither network nor key — the chain ends in a whisper-ctranslate2 subprocess, 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_transcribe tool all go through MediaUnderstandingService; 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 the media_transcripts cache per file and chain; brain-facing detail in docs/brain-dev/voice.md
    • Two input forms, both a file on disk — TranscribeInput takes path (fenced and capped by MAX_LOCAL_AUDIO_BYTES) or url (SSRF-guarded fetch into a scratch dir, capped by DEFAULT_AUDIO_MAX_BYTES), with a MIN_AUDIO_FILE_BYTES floor that skips empty or corrupt audio
    • Declared per-step limits — TranscriptionEntry.limits (maxBytes / maxDurationSec) marks a step skipped with both numbers rather than running it, carrying each model’s own caps: 25 MB for groq and openai, plus the 1500 s ceiling gpt-4o-*-transcribe enforces at any bitrate
    • Speech track before upload — a file over the chain’s smallest declared maxBytes is 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, plus chosen) 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
  • 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 KeyedSingleFlight beside the read-through cache collapses readers that arrive together, so concurrent consumers of one fileUniqueId heard by one chain share a single download and a single chain run
  • LLM-Facing Media Tools — four tools registered on the integration tool registry, all under the media:process capability; the three that read media download it through fetchRemoteMedia and translate MediaFetchError into a tool failure(...)
    • image_generate — runs the image role of constitution/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 no image role gets the engine default openai:gpt-image-2.5-sunburst, and a step on a provider the engine builds no image model for (only openai today) fails the call rather than being skipped. With widget: { 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 with file_send / telegram_send / artifact_publish; a provider answer with no image is a failure, never an empty success; moderate risk with a per-minute rate limit, and a description that tells the model to refine the prompt rather than retry blindly
    • audio_transcribe — wraps MediaUnderstandingService.transcribeAudio({ url | path, language? }); the path form accepts a local file, which is the transport past the Bot API 20 MB getFile limit
    • image_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: takes url or path, detects the type from the bytes (extension and header as fallback) and dispatches — PDF through extractPdfContent (caps maxPages 1–50, maxPixels 2 000 000, minTextChars 100, optional pageNumbers; 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, imagePages names them and omittedImagePages the rest, read with pageNumbers), Word / Excel / PowerPoint / OpenDocument / RTF / EPUB / HTML through extractOfficeText as markdown, and txt / md / csv / json / xml / yaml as-is; text past maxChars (default 50 000, max 200 000) is cut with a marker and a nextOffset to continue from via offset. 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 — MediaAttachment is the single representation carried unchanged from Telegram ingress through tool results, chat input, LLM message building, agent input, both agent boundaries (multipart UserContent for API agents, file URLs appended to the prompt for CLI agents — each non-image entry names the tool that reads it, document_read or audio_transcribe, and so does an API agent’s description of a file it cannot receive as bytes), stream UI finalize and persistence as AttachmentRef in conversation_events.metadata
    • Eager URL resolution — the Telegram bridge resolves fileId → URL at ingress, so MediaAttachment.url is always populated by the time it reaches the domain layer and no lazy FileResolver interface exists
  • 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, RawMessageInput instead of a 67-field MsgContext), 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 raise IntegrationValidationError
    • Registration fan-out — registerIntegrationKitTools runs in every app that builds a ToolRegistry (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, optional verifySignature, and error containment (a throw is a logged 500)
  • Token Providers — one interface (getAccessToken(subjectId)), two interchangeable implementations, invisible to tools
    • EnvTokenProvider — zero-infra: a prefixed env var, no refresh, no expiry; TokenUnavailableError when unset
    • DatabaseTokenProvider — per-subject lookup plus reactive refresh, wired automatically for every provider declared in defineProject({ oauthProviders })
  • OAuth Connection Store — encrypted per-subject credential storage shared by the MCP surface and brain-declared providers
    • Tables — connections (exactly one of mcp_server_url / provider_id, CHECK-enforced; mcp_oauth / static_bearer / no_auth; active / refresh_failed / revoked; archived rather than deleted) and oauth_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-byte CONNECTIONS_TOKEN_ENC_KEY validated at boot, and a decrypt failure raises ConnectionDecryptError after incrementing connection_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 and active), 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:, with null for genuinely headless callsites; synthetic placeholder principals are forbidden
  • Reactive Token Refresh — refresh happens just-in-time at hand-off, never on a background sweep
    • Single owner — refreshUnderLock serves 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_enc bytes 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 stamped refresh_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.terminalRefreshErrors lets a brain add e.g. invalid_request for Ory Hydra-fronted servers that answer off-spec, so a dead connection is recognized instead of retried forever
    • needsRefresh rules — 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 — forceRefreshConnection runs the same locked path with an infinite buffer for connection_renew (the dashboard’s “Refresh now”), guarding up front so a non-refreshable row is rejected rather than flipped to refresh_failed, and normalizing both surfaces’ errors into one ConnectionRefreshFailedError
  • Self-Reporting Connections — a connection whose refresh token dies announces itself by name in the status chat
    • One writer, exactly once — markRefreshFailed CASes the row and emits connection: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 the refresh_failed row and the TokenUnavailableError re-raised on every later fetch
  • 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 TokenUnavailableError calls hitl_request_connection, pauses on an oauth_consent HITL 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|mcp shares runOAuthPasteBackFlow: print the authorize URL, prompt for the pasted address bar, validate state and code XOR error locally, forward the query verbatim, and take the route’s status code as the verdict (falling back to /oauth/status/:hitlId on 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_MS is 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_URL is the redirect_uri string a browser types (and the availability gate for the OAuth routes); WEBHOOKS_INTERNAL_URL is what sibling services dial
  • Robot OAuth Authorization (autoAuthorize) — a provider can hand the consent screen to a browser agent instead of a human
    • Flow — /oauth/init also enqueues an oauth-auto-authorize job that spawns system:browser to open the authorize URL, log in, clear an emailed one-time code via email_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_PASSWORD declared 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_URL set; 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 holds email:read on the mailbox
  • 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-http remote 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/servers page 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 from defineProject({ oauthProviders }) at startup, archived when removed from config)
    • Install routing — integration_install branches on source first then auth_type: oauth returns an authorize URL the browser navigates to, no_auth upserts immediately; byot is the dashboard’s alone (the person pastes the token, it becomes a static_bearer row) — 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, connected computed by a single batched join against connections
    • Curation — integrationsStore.mcpAllowed filters official rows by reverse-DNS namespace; brain-declared and custom are never filtered
    • Metrics — mcp_catalog_sync_total{result} and mcp_catalog_entries_total{source,status}
  • 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> (the service ref); 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 as revoked, 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’s type (mcp_oauth, static_bearer, no_auth, mtproto) on the list too, and an integration bundle’s declared webhooks (/webhooks/<bundle>/<path>)
    • Authorize / renew / revoke — connection_authorize returns the consent URL without waiting (the callback revives the row in place; an agent that must wait keeps hitl_request_connection), connection_renew force-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_messages lists an account’s sends with their text read from Telegram for the page, never stored
  • Pre-Registered OAuth Clients — defineProject({ oauthClients }) seeds oauth_clients rows from env for authorization servers that don’t support RFC 7591 dynamic client registration, making them runtime-indistinguishable from DCR-issued clients; oauthCredentialResolver lets a brain source (clientId, clientSecret) from KMS/Vault instead of the ${PROVIDER}_CLIENT_ID convention
  • BYOT Admin API — POST /api/connections/byot (bearer-authed, .strict() body) encrypts a pasted token into a static_bearer connection, 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’s color from Google’s 11-color palette, default on 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_file streams 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 unless exportFormat names another; drive_upload_file takes text content or a file_path from the agent’s own artifacts directory (the same fence as file_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_file returns Docs as plain text, Sheets as CSV and text files as-is, and fails permanently on a binary file, pointing at drive_download_file
    • 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
  • 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 exact quote, forum topicId, parseMode markdown|html, silent, noLinkPreview, scheduleAt), telegram_scheduled (list / delete / send-now a chat’s scheduled queue — entries are the telegram_history message shape with date = 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 a t.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 in detailUnavailable); on existing messages telegram_edit_message, telegram_delete_messages (ids are checked against the chat first; forEveryone in private chats and basic groups, always for everyone in channels), telegram_react (set / remove, emoji or custom emoji), telegram_pin_message (pinned, onlyForMe, silent, all to 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 (ISO date); unreadOnly, archived true = Archive only / false = main list only, folderId — unread and folder filters scan the list client-side, so page with the returned nextOffsetDate), telegram_archive_chat (archived), telegram_mute_chat (muted, optional until as 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 via checkChatInvite without 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’s kind says which), telegram_contacts (the contact book sorted by name with offset paging, or a query search — your contacts and chat partners first, then global public usernames with isContact: false; people only), telegram_add_contact (add or rename by @username / user id, or by phone — finds the account registered to the number, CONTACT_NOT_FOUND when none; sharePhone shows them your number), telegram_delete_contact, telegram_block (user + blocked true/false, or no user for the block list), telegram_user_info (users.getFullUser: bio, last seen — an exact time only when the user shares it —, birthday, business hours as mon 09:00 in 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 with nextAfterChatId). Every chat these tools name is one shape — { chatId, chatType, title, username, isBot }, chatType being private | group | supergroup | channel like 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 returns status: requested), telegram_leave, telegram_create_group (chatType group | supergroup | channel, about, forum, members — refused members come back in notAdded; a member failure after creation is reported in membersError, never by re-creating), telegram_invite_members (per-user added / notAdded with Telegram’s reason — a privacy refusal Telegram reports silently, too), telegram_ban_member (kick / ban with optional until / unban; basic groups have no ban list — kick only), 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; topicId feeds telegram_send and telegram_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. method is the TL name from core.telegram.org (messages.getFullChat), params its snake_case fields (camelCase accepted); longs go as decimal strings, peer fields take @username / chat id / me at 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_read admits only get* / search* / check* / resolve* methods, minus the read-named ones that write (contacts.getLocated, messages.getBotCallbackAnswer, messages.getMessagesViews with increment) — NOT_A_READ_METHOD otherwise; 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 with allow_paid_stars (INVOKE_DENIED) — and both are refused from a business chat. The reply comes back as JSON (className kept, longs as strings, bytes removed) capped at maxChars (engine default 20000, per call up to 200000) with a truncated flag; own messages a raw send delivers are announced to the inbound listener’s loop guard like any send. Own rate buckets invoke_read (20/min) and invoke (5/min, 100/day)
    • Capabilities — reads are messaging:read / contact:read; sending is messaging:send (approval); edit / delete / react / pin / mark-read / archive / mute / drafts / folders / scheduled are messaging:manage; contact-book changes are contact:write; join / leave / create / invite / ban / invite links / forum topics are access:admin (approval); raw calls are telegram:invoke_read (read-prefixed methods) and telegram:invoke (approval)
    • Send or forward — telegram_send either sends text and/or files or, given forward: { fromChatId, messageId, dropAuthor? } and an empty text, forwards an existing message so its media, caption and clickable links arrive intact with no download/re-upload; a forward draws on the same send rate-limit bucket; a source that is gone or locked fails permanently (MESSAGE_ID_INVALID / CHAT_FORWARDS_RESTRICTED from Telegram, MESSAGE_NOT_FOUND when Telegram returns no forwarded message), and a source chat that does not resolve fails as FORWARD_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) or telegram_scheduled’s send-now is one action-log row telegram.send against the chat (tg:chat:<id>), attributed to the calling agent with { account, messageId } — never the text; telegram_send answers the chat’s marked id (chatId) beside the message ids. “Sent as you” (service_list_messages) and audit_list read them
    • A limited account is engine state (since 1.27.0) — a call that fails with PEER_FLOOD, or a send whose FLOOD_WAIT reaches telegram.accountLimit.floodWaitMinutes (60 by default), records the account limited { reason, since, until? } before the failure returns, in whichever process sent; telegram_account_status (messaging:read) and the pipeline SDK’s telegramAccountStatus(ctx, account?) read it, so a pipeline gates cold sends on it instead of keeping its own flag (telegram_send itself is not refused — a limited account may still answer whoever wrote first). The worker asks @SpamBot (/start to the verified bot only, never a stranger; at once, then every checkEveryHours, 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 outlives alertAfterDays (2 by default) and when it lifts; a FLOOD_WAIT limit ends at its until, unannounced. telegram_account_check (messaging:manage) asks now, or with clear: true lifts 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 live TelegramClient sockets 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 enabled mtproto: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 hook filter accepts are kept. The message part is the telegram_history shape plus sender, chatType, outgoing, mentionsMe, topicId, sentByAgent (loop guard: every engine send is recorded in omnislash.mtproto_sent_messages; a fresh outgoing message the record has not reached yet is looked up once more), fromScheduled (delivered from the scheduled queue) and secretaryBound (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 in mtproto_sessions and advanced only after the event is durable; restart, reconnect, lease handover and 15 quiet minutes catch up with updates.getDifference / getChannelDifference; a channel first heard of after downtime is read back to the last sync; a first run starts from updates.getState; a range Telegram refuses to replay is published as mtproto:gap. Exactly-once: each event carries its permanent key as id (<account>:<chatId>:<messageId>:<kind>), is written to external_events (source mtproto) 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 after telegram.inbound.payloadRetentionDays in constitution/MANIFEST.yaml (engine 7 days); the key stays. Own rate bucket updates (60/min). omnislash doctor (MTProto hook accounts) warns about a hook no authorized account can feed
  • 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
  • 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_search and perplexity_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 optional n8n service 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’s category, which is how a brain verifies a voice id its plan can actually reach), elevenlabs_sound_effect
  • Web Search — web_search over 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_fetch reads 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) and image_process (resize / convert / optimize), always registered with no external service dependency
  • OpenAI Media — image_generate (image generation), always available since OPENAI_API_KEY is required; the model chain is the image role in models.json
  • Multi-Provider Transcription — audio_transcribe runs MediaUnderstandingService over a brain-declared chain (transcription.chain, defaulting to Groq → OpenAI → Deepgram → local whisper-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-ctranslate2 step needs neither
  • 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 with widget, else a file for an agent and base64 for a caller with no agent) and voice_send (synthesizes and delivers)
    • The chain — speech.chain is 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. Omitting speech gives ElevenLabs → OpenAI with stock voices
    • openai step — gpt-4o-mini-tts with 13 named presets and free-text instructions for delivery direction; pay-as-you-go, so no allowance cliff
    • elevenlabs step — eleven_flash_v2_5 with 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_send blocks on the notification worker’s result and returns the delivered messageId, so a failed delivery is a failed tool call rather than a confident “sent”
  • Notification Channels — named, brain-declared delivery destinations that agents can use without approval cards
    • The model — notifications.channels declares a public name + an LLM-facing description in the committed config; the private chat id resolves at boot from a fixed NOTIFY_<NAME>_CHAT_ID convention 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) and owner (the primary owner’s DM — the first TELEGRAM_ALLOWED_IDS entry, numeric by validation; never the HITL approvals chat); owner drops 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 as context.notify), markdown by default with safe degradation and automatic chunking, delivery confirmed by returning the landed messageId
    • Why there is no chat-id form — the notification:send capability is admissible as hitl:false only because the destination is never free-form; a raw chat id belongs to messaging:send and needs approval, which is also why queue_create_job exposes neither the ui-notifications nor the workflow-execution lane
  • context.notify — delivery for brain code — every tool body receives a guaranteed notify(req) on its ToolExecutionContext, injected by ToolsGateway at the same seam as getServiceToken
    • Addressing — by channel name or by the calling turn’s own surface (persona included), never by chat id, so the hitl:false story is unchanged; a headless turn without a target lands on status
    • Payloads — { text, parseMode }, a native sticker: { fileId }, and replyToMessageId that 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 + sticker ride one job retried whole, so a sticker failure would re-send the text
  • LLM Provider Surface — the engine wires five AI SDK API providers directly — openai, anthropic (CLAUDE_API_KEY), nvidia (custom base URL) and two aggregators, openrouter and vercel (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 doctor is where to look for it

CLI

  • Single Entry Point — omnislash (citty) is the only interface a brain developer needs, run from the brain root or from the engine root with BRAIN_ROOT=<path>
    • Lazy subcommands — each command is an import thunk, so --help and 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. --verbose raises 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 --stdio guard), so TOKEN=$(omnislash git token owner/repo) captures the token alone and … --json | jq gets 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 an omni shorthand) so it can be docker compose exec’d in production
    • Upgrade notice — dev, start and doctor check the registry and announce a newer engine release
  • 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 the TELEGRAM_* 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 spawned regen → git commit --no-verify; nothing is written until every input is accepted, and each step leaves a valid tree behind if it fails. --no-git governs the commit as well as the git 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 — a bots: [] brain must not inherit a sibling brain’s live TELEGRAM_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_SECRET and the two CONNECTIONS_* keys are minted rather than asked for. Secrets have no flag twin on purpose — argv reaches shell history and ps
    • Owners are numeric ids — TELEGRAM_ALLOWED_IDS takes numeric Telegram user ids only (a @username can 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 unless TELEGRAM_HITL_CHAT_ID names another chat, and gets the owner notification channel and restart DMs
    • Ends on one command — the summary names what is still missing and prints exactly cd <dir> && omnislash start
  • omnislash dev — engine-developer mode: infra in Docker, apps as tsx watch child processes
    • Flags — --inspect (Node inspector), --skipDb (skip migrations), --only bot,worker,…
    • What it does — resolve brain root → load config + walk-up .env → bring up templateDirs: ['infra'] (postgres, redis, prometheus, optional restate/playwright/browser/n8n/nocodb) → run migrations via the same db:migrate code path as production → spawn every service with a devEntry in SERVICE_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 composeStop is guaranteed to run exactly once via finally, so infra is never orphaned
  • omnislash start — brain-developer mode: everything in Docker, brain root bind-mounted for hot reload
    • Flags — --skipDb (drop the db-migrate service 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, then runBrainPreflight on the peer-dep shape / .npmrc / config — all before docker build burns minutes
    • Health gate is brain-tunable — docker compose up --wait runs under startup.waitTimeoutSec (engine default 180s, raised per brain in omnislash.config.ts), long enough to outlast the scheduler container’s own start_period on 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
  • omnislash build — regenerates the brain Dockerfile + .dockerignore from the pinned CLI version (or uses Dockerfile.override) and builds the brain image on top of the engine base; --noCache available
  • omnislash compose — generates compose + monitoring + helper configs under .omnislash/; --prod selects the production templates, --image <img> sets the brain image tag
  • omnislash stop / down / reset / clean — environment lifecycle
    • stop — 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|start and iterate every resolved compose context
    • clean — removes .omnislash/, node_modules/, .turbo/, coverage/ (engine monorepo only)
  • omnislash logs [service] — container logs with --follow/-f, --tail/-n (default 100), --mode
  • omnislash sync — runs the scheduler once (--once) for a forced manifest reconciliation; engine-source-only, resolves DB/Redis from the running compose
  • omnislash 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 — env validates process.env against the schema with no project root, pipelines imports and validates every enabled pipeline module (and fails, never skips, on a missing project root or a config that does not load), engine checks the installed engine is whole; an unknown --check value prints the valid list and exits 1 instead of running the full report; --json prints 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 — fix is per-branch and optional, which is why it lives on the result and not on the Check). The dashboard renders the same string and executes nothing
    • --fix — applies the subset the CLI can perform itself (a closed DoctorFixAction union, 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 bare doctor writes 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
  • omnislash install — installs missing skill binary dependencies from each SKILL.md install[] spec (apt / uv / node / brew / go); --yes skips the prompt, --dryRun prints the plan, 5-minute per-item timeout
  • omnislash connect — manage MCP and brain-declared OAuth provider connections
    • connect provider <id> — the no-ingress OAuth bootstrap for a defineProject({ oauthProviders }) entry; always authorizes as DASHBOARD_SUBJECT (dash:default, deployment-wide), no --subject flag
    • connect mcp <url> --subject <subject> — same paste-back flow for an MCP server; per-subject, so --subject is mandatory under --prod
    • --prod — re-invokes the same command inside the production webhooks container over ssh -t + docker compose exec (TTY end to end), using PROD_SERVER_HOST + PROD_PROJECT_DIR
    • connect 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 with pnpm -C packages/infra db:* and the db-migrate container
    • db migrate — apply pending migrations, engine line then brain; the only command any automatic path runs
    • db status — read-only per-line [✓] tag / [ ] tag (pending); creates nothing, not even the journal
    • db generate [--name X] [--engine] — diff the TypeScript schema against the line’s head snapshot and write NNNN_name.sql + snapshot + journal entry; opens no connection. --engine is refused outside the engine monorepo
    • db baseline --force — stamp every missing migration as applied without executing it; refuses to run without --force
    • db push — local-dev-only schema push, on no automatic path, warns when a journal exists
    • db studio — Drizzle Studio browser
    • --mode dev|start — used only to derive compose credentials when DATABASE_URL is absent
  • omnislash git — diagnostics for this deploy’s GitHub App
    • Preconditions checked once, in one wording — a resolvable Postgres DSN for every subcommand (DATABASE_URL or the POSTGRES_* components — the default managed-postgres deploy leaves DATABASE_URL unset, so the group never gates on that one variable), plus CONNECTIONS_TOKEN_ENC_KEY for 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), --json for the raw rows
    • git token <owner/repo> — mint a short-lived installation token scoped to that one repository; the bare token goes to stdout and expires_at=… installation=… to stderr, so TOKEN=$(omnislash git token owner/repo) captures exactly the token (--json prints { token, expiresAt, installationId, repository } instead). Uncached on purpose — a diagnostic must not populate or consume the runtime cache
    • git repos — the attached code_repos rows 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; --json for both halves
    • git attach <owner/repo> [--slug <s>] [--branch <b>] — write a code_repos row: slug defaults to the repo name normalized to the slug charset (toRepoSlug — lowercased, every other character run collapsed to -, so My_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 --branch is what both clone paths pass to git 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 the origin mismatch instead of pulling it
    • git detach <slug> — drop the row. The /repos/<slug> checkout is left on disk; nothing reads it once the row is gone
  • omnislash hooks trigger <id> — fire a hook manually for testing, with --payload <json> template variables and --event to override the declared event name
  • omnislash pipeline — Restate pipeline operations
    • pipeline run <name> [input] — trigger an execution, with --target-key, --idempotency-key (default random UUID) and --handler (default run)
    • pipeline status --name <n> — check execution status by idempotency/target key and handler
    • pipeline reconcile — drain removed services, sync terminal status and flag orphaned pipeline_invocations rows; --dry-run by default, --apply to mutate, --json for the full report
  • omnislash workflow reconcile — re-drive workflow_runs whose Restate attempt is gone and parked runs with no waker left; --dry-run by default, --apply, --json
  • omnislash 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, --pick or positionals may specify fixtures
  • omnislash skills — skill store management
    • Subcommands — add <url> [--as] [--force], install (restore from skills/.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]
  • omnislash upgrade [version] — bumps every @omnislash-ai/* peer to one exact engine version, re-resolves the lockfile, regenerates every engine-owned artifact, syncs docs/engine/ and skills/engine/, writes the agent task upgrade/TASK.md, and offers to commit
    • Refuses a contract-violating manifest — any @omnislash-ai/* outside peerDependencies aborts the run
    • Cross-version bridge — regeneration and the task are delegated to the freshly installed CLI’s public regen and upgrade-plan subcommands, because only the new CLI carries the new templates and upgrade entries
    • Agent task — plans (current pin, target] (--from overrides) into upgrade/, 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
  • 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 steps when the range has none
  • omnislash regen — rewrites every engine-owned artifact from the installed engine, unconditionally, so it also heals same-version corruption that upgrade’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 --> in CLAUDE.md (and a real AGENTS.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]
  • 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 (default root), --app-user (default omnislash), --swap (GB, 0 leaves 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’s apparmor_restrict_unprivileged_userns alone), the bot mode defaults to polling and 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 and ps
    • The remote half is a real .sh asset, not a TS template literal (a \033 inside a JS string silently stops being an ANSI escape), streamed over ssh … bash -s behind a prelude of shell assignments whose every value is validated against a closed charset before it is quoted. Each step is idempotent — ssh-keygen under [ -f ], swap under swapon --show, docker under command -v, and authorized_keys merged 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, no setup_24.x | bash, no npm 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’s Host 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 set on stdin. It reaches no scrollback and no CI log; secret values never touch argv, which ps shows 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.sh with the workflow’s automatic GITHUB_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 its SHA256: 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 exact gh command for every name only you can supply. A missing gh, 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.NAME extracted from deploy.yml + ci.yml minus the automatic GITHUB_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.override sentinel hands it over (and nothing at all when its owner deleted it) — the same sentinel ensureDeployYml/ensureCiYml honour when they decline to write. The handoff and the doctor’s GitHub Actions config check 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
    • .env learns where this brain deploys — PROD_SERVER_HOST + PROD_PROJECT_DIR are upserted in place (existing keys edited where they stand, comments and order untouched, mode 0600 preserved), which is what auth-agents, connect --prod and the deployment doctor checks already read
  • 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), then ssh -t … docker compose exec pipelines <login> — exec into the already-running owner of the credential mount, never a oneoff (a second refresh-token writer invalidates the OAuth family)
  • 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 Dockerfile at 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, with exec "$@" as the fallback for arbitrary commands
    • PID 1 — dumb-init for signal forwarding and zombie reaping; each service exec node directly, 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) for bot/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 @latest in their own layer, per-harness bubblewrap shims, Docker CLI, the omnislash/omni wrappers, plus ripgrep, fd, jq, yq, tree, rsync, python3+uv for skill scripts
  • Base Image Extension — a brain image is FROM ghcr.io/omnislash-ai/engine:X.Y.Z plus the brain’s own R-set and dependencies
    • Generated Dockerfile — rewritten on every build/start from CLI templates pinned to the installed engine version; Dockerfile.override takes 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 each SKILL.md become apt-get / uv tool install / npm install -g lines run as root, followed by a node-user verify step that fails the build if a baked binary isn’t on PATH
    • .dockerignore as the contract — the R-set is not enumerated; a single COPY . ./brain/ ships everything except the resolved G-set plus ephemerals, so new brain-root paths need no engine change
  • 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_REGISTRY is the single engine-tier statement of where the engine is published; the scaffolded .npmrc scope line, the doctor contract check, the version probe, the generated workflow auth blocks and the auth handed to a spawned pnpm install all 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/cli its own install placed on disk and spawns omnislash init with the arguments verbatim. It is the one workspace package targeting public npm (that is where pnpm create resolves 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 is pnpm 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 fixed group 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) and publishConfig.exports (dist, what a brain actually sees) are held in step by pnpm check:publish-exports, with a NARROWED map for packages that deliberately publish less than they export
  • 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 upgrade prints the unfinished task, --from re-plans a range into it (ticks kept), doctor upgrade-in-progress names the next entry
  • Compose Generation — the CLI generates every compose file from bundled templates; there is no static docker-compose.yml anywhere
    • 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, its depends_on references, 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, and deploy.sh
    • Brain-state subpath mounts — injectBrainStateMounts() adds one brain_state subpath mount per G-path to every consumesBrainState service; 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 and injectFleetInternalUrls writes MCP_INTERNAL_URL, WEBHOOKS_INTERNAL_URL, RESTATE_INGRESS_URL, RESTATE_ADMIN_URL and RESTATE_DEPLOYMENT_URL into every engine-image service, skipping any address whose service this compose filters out
  • Compose Override — a brain drops docker/compose.{prod,start}.yml and 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: replace to 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 upgrade writes docker/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
  • Production Env Validation — required env is derived from the templates themselves, so adding a ${VAR} auto-extends the check
    • Rule — any ${VAR} without a :-default in 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 at up time (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 by deploy.sh and are invisible to the template scan; and ADMIN_USER + ADMIN_PASS_HASH in 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
  • Domain Mode with Auto-TLS — setting PUBLIC_DOMAIN + ACME_EMAIL turns 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 the caddy_data volume
    • 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 guards visibility: 'private' published artifacts
    • Derived origins — WEBHOOKS_PUBLIC_URL and the artifacts public URL are auto-derived from PUBLIC_DOMAIN, so OAuth installs work without a second variable
    • IP mode — a self-signed cert on :8443 for servers with no domain; compose --prod fail-fasts on caddy: false together with TELEGRAM_WEBHOOK_URL
    • Extra-vhost seam — a generic caddy-extra bind (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
  • 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.yml is 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 --prod writes .omnislash/deploy-plan (stop|kill|migrate|up|run lines): producers, then the worker and pipelines, stop; engine Restate invocations outside CROSSES_A_DEPLOY are killed; brain-sync stops; db-migrate runs; one up -d --wait starts everything; one-shots run --rm last
    • Migration lock wait is brain-tunable — db-migrate runs 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 most migrations.lockTimeoutMs (engine default 5 s) for a table one of them holds, and retries up to migrations.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_SHA256 so the deploy’s up -d --wait recreates 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_proxy in 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
  • Secrets Management — the engine contracts only on a .env file existing on the host at up -d time
    • Default flow — the deploy job SSHes in and runs infisical export into $DEPLOY_DIR/.env using 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 .env by hand
  • Deployment Tuning Knobs — topology lives in .env / compose, never in omnislash.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>_CPUS for every app, data and sidecar service (bot, worker, pipelines, dashboard, mcp, webhooks, scheduler, monitoring, brain-sync, caddy, postgres, redis, restate, playwright, browser, n8n, nocodb), plus WORKER_REPLICAS (3), AGENT_EXECUTION_CONCURRENCY (12, a cluster-wide Restate semaphore), PIPELINES_PIDS / PLAYWRIGHT_PIDS / BROWSER_PIDS / WEBHOOKS_PIDS, REDIS_MAXMEMORY and RESTATE_ROCKSDB_MEMORY, each with its default in operations.md § Server size. A retired spelling (PIPELINES_MEMORY_LIMIT, WEBHOOKS_MEMORY_LIMIT) fails compose --prod naming its replacement, rather than silently running the default
    • Host resources doctor 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_dump sidecar cadence and rotation
  • Production Hardening — every prod app service ships with healthchecks, deploy.resources limits, security_opt: no-new-privileges, read_only where possible, tmpfs, json-file log rotation, stop_grace_period and oom_score_adj, on three isolated networks (frontend, backend, monitoring)
  • Volume Architecture — one named volume per durability class
    • brain_state — the G-set, mounted whole by brain-sync (where .git lives, 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 views
    • brain_baseline — brain-sync’s full clone, the git-alternates source for per-run sandbox checkouts; workers mount it read-only
    • worker_runs — per-run sandbox checkouts, surviving restarts so a HITL pause→resume works
    • ./artifacts host bind — the W-set at $ARTIFACTS_ROOT, a sibling of /app/brain so a per-run sandbox bind can never shadow it
    • postgres_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_DIR points into the mount so a recreate doesn’t blank the OAuth config
    • Single writer rule — only the running pipelines container may hold the rw credential mount; a second container sharing it becomes a rogue refresh-token writer and invalidates the OAuth family. auth-agents and the dashboard web terminal both exec into that one container
    • Doctor coverage — credential file presence, token validity/expiry, PROD_SERVER_HOST, SSH connectivity and deployed image versions
  • 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 login prints its URL as plain text and reads the pasted code off stdin, codex login --device-auth prints 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.codeEntry discriminates 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) and none (Codex — link plus code posted, nothing comes back through chat)
    • cli-auth Virtual Object, keyed per harness — runs in pipelines because 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 shape agent-vo uses 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 — /connect itself 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 --force to 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
  • Prod SSH Seam — execInProdService (packages/cli/src/prod-ssh.ts) is the one code path for running a CLI command inside a production container over ssh -t + docker compose exec; shared by auth-agents and connect --prod
  • Post-deploy image cleanup — scripts/docker-cleanup.sh runs 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 resident maintenance container 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/ and templates/infra/ is pinned to a concrete tag and every third-party uses: in the generated deploy.yml to a full version; image-pins.test.ts fails CI on a :latest/tagless/bare-major image (redis:8-alpine moved to a new patch under a deploy and restarted Redis under every app) or a floating-major action, and requires redis and postgres — 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 in public (bare pgTable() 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/UpdateParams derive in schema/types.ts via Omit/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) via DATABASE_URL
  • Two Ordered Migration Lines — schema reaches a database only through committed, ordered SQL
    • Engine line — packages/infra/migrations/, PG schema omnislash, journal drizzle.__engine_migrations, applied first and unconditionally so a brain table may FK into omnislash.* but never the reverse
    • Brain line — {brainRoot}/db/migrations/ against {brainRoot}/db/schema.ts, PG schema public, journal drizzle.__brain_migrations; the schema path is a convention probe, not a config key (BRAIN_SCHEMA_PATH is gone)
    • Neutral journal schema — both journals live in a third drizzle schema so the drift oracle’s per-line schemaFilter never proposes dropping them
    • Brain snapshot isolation — the brain snapshot is generated from the brain module alone; schemaFilter scopes 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.ts serves the container (db-migrate), the monorepo (pnpm -C packages/infra db:*) and a brain (omnislash db … via the ./db-cli subpath export)
  • 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 because DATABASE_URL may 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 = 0 and search_path = public re-applied with SET LOCAL inside 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-migrate one-shot’s service_completed_successfully gate and stops the deploy, instead of crashlooping workers against a half-applied schema
  • -- omnislash:no-transaction Migrations — an opt-out for statements Postgres forbids inside a transaction block
    • Enforcement at read time — a file containing CREATE/DROP INDEX CONCURRENTLY without the marker throws before anything is applied, and generate adds the marker automatically when the diff contains one
    • Invalid-index cleanup — a failed CREATE INDEX CONCURRENTLY leaves an INVALID index holding the name; the runner drops it before rethrowing, so the retry isn’t wedged forever
  • Migration Authoring Flow — generate, read, apply, commit the SQL and the snapshot
    • generate never touches a database — it diffs the TypeScript schema against the line’s head snapshot and writes NNNN_name.sql + meta/NNNN_snapshot.json + the journal entry
    • Destructive-statement warning — DROP COLUMN / DROP TABLE / DROP SCHEMA / DROP TYPE are 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 “edited schema.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() uses drizzle-kit push’s diff engine as an oracle and classifyDrift() sorts its output into blocking (missing objects — stops the deploy), extra (undeclared objects, e.g. tables an agent created with db_execute — logged, left alone) and tolerated (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 public is 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 --force stamps everything without introspecting, which is also the answer when the drizzle-kit patch the oracle needs isn’t present in a brain’s own node_modules
  • Engine Schema Surface — 43 tables in the omnislash schema 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_slug throws for readers that score or repair a run (eval, loop, recovery) and is dropped-with-log.error for 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.executions holds 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 by triggerOf, never stored), the execution it ran inside (parent_execution_id) or re-runs (rerun_of), its detail row (detail_id), expected_at for a scheduled start, started_at / ended_at, status + status_reason, error_class, and cost_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 derived exe_… 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_id names 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). A scope: chat hook’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 pipeline runAgent, 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 with cycleStepOf) — 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 under loop.watch.mode: none. The pre-merge check’s replays are runs inside its check step, 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
  • 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), stripIdPrefix drops 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_prompts is 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_store is 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 — BlobStorageRouter routes 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-swept blobs/.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, and retrieve() 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 — BlobTooLargeError is 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 so processApiStream and CLI agents can read it natively; content addressing lives in the directory layout
  • 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 its agents/<id>/ directory; an agent’s pnpm install keeps its store in the container’s ~/.cache/pnpm, never on the artifacts volume
    • Agent-row retention — agentRetention.ttlHours soft-deletes then hard-deletes agent rows via the pipelines service’s sweep (disabling services.pipelines therefore 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
  • 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 / createConnectionFromEnv with 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=disable skip TLS entirely
    • Query budget — DATABASE_QUERY_* timeouts at the app level, explicitly neutralized inside migration transactions so DDL is never aborted midway
    • Resilience helpers — withRetry with transient-error classification, plus isUniqueViolation / isNotFoundError / isTransientError predicates on the error boundary
  • Backups — a postgres-backup sidecar (pg_dump on BACKUP_SCHEDULE, day/week/month rotation into the postgres_backups volume) 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-missing alert to Telegram
    • Streamed restore — dump is piped container-to-container (postgres-backup cat | gunzip | postgres psql), no host paths and no root
  • Drizzle Studio — omnislash db studio opens 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 db subcommand takes DATABASE_URL from 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 --prod flag, and db push / omnislash start against 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 each jobId by 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 / JobHandle interfaces with a BullMQ implementation, so producers never import BullMQ directly
  • 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 AbortSignal threaded 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:*') / observeAll read it live (pub/sub, lossy), consume(group) durably (once per group, caught up after a restart), with Zod validation at both ends and an onEventError channel 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.run step in the entity’s durable owner, a consume group 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 — RedisLeaderLease on reducer:leader elects the one worker replica that reduces eval:* / planning:* into a snapshot; the lease releases immediately on clean shutdown, and EvalChannel heals the crash gap by reconciling still-running snapshots 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 / WildcardMatch helpers driven by the schema union, plus per-event .meta({ category: 'system' | 'user' }) that drives activity-feed filtering
  • Webhooks Service — a Hono HTTP→queue bridge on WEBHOOK_PORT (3200)
    • Tool execution — POST /tool/:name runs any registered tool with webhook !== false under 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 own event_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_ingest and a hook
    • Event ingest — POST /event/:source looks up a brain defineEventSource, calls its extract() (returning null → 401), then records it through event.ingest’s own function (ingestEvent, @omnislash-ai/engine-api/event-ingest) and dedups by persisting: one upsert into omnislash.external_events decides insert / revive / duplicate; a 202 means 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_events is the one inbox for every inbound event (webhook ingest, event_ingest, the Telegram user-account listener): a recorded row wakes one keyed inbound-route job 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 outbound dispatch targets on it, marks the row processed and 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 ends failed with the reason, and a re-delivery of its key routes it again. Rows carry resources (correlation keys such as github:pr:<owner>/<repo>#<n>)
    • Body size cap — every anonymous door (/event/:source, /github/webhook, integration routes) answers 413 before buffering a body over its cap: 25 MiB by default, a source’s own maxBodyBytes, 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 shared WEBHOOK_SECRET for that door alone (itself checked the same timing-safe way); bearerToken(...secrets) is the shipped implementation, comparing SHA-256 digests via timingSafeEqual and 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 single INSERT … ON CONFLICT … RETURNING that ingests the event, so there is no read-then-write window and no expiry: a key is taken forever. A key whose routing ended failed is 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/:path mounted from defineIntegration({ webhooks }), authorized by the handler’s own verifySignature rather than the bearer token
    • Raw body capture — rawBodyMiddleware snapshots the exact Buffer before 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 and git_request_app hand out: it verifies the signed state without burning it (a reload works) and answers an auto-submitting form that POSTs the manifest — built once at boot from git.appPermissions / git.appPublic — to GitHub’s (org or personal) registration screen, no-store, no-referrer, a CSP whose form-action is github.com only
    • GitHub App registration callback — GET /github/app-callback: it verifies an HMAC-signed state, burns its nonce with a Redis SET NX replay guard, converts GitHub’s one-hour manifest code into the App’s credentials, seals them into omnislash.git_apps and continues straight to GitHub’s install screen under a fresh install state, carrying an agent card’s binding over to it. Mounted only when both WEBHOOKS_PUBLIC_URL and DASHBOARD_PUBLIC_URL resolve
    • GitHub App installation callback — GET /github/install-callback, the App manifest’s setup_url: the state is optional (an install can start straight from the App’s GitHub page) but is verified and nonce-burned when present, and every column of the omnislash.git_installations row comes from an App-JWT installation read, so a forged installation_id gets a 400 and writes nothing. A flow an agent’s git_request_app card 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-256 is verified as HMAC-SHA256 over the RAW body (never a re-serialization) against the sealed webhook_secret, length-guarded before timingSafeEqual, and a missing header, a bad signature or no stored secret all end at 401 having done nothing. A verified delivery is recorded and answered 202 (200 duplicate for a redelivery); an event or action outside the github:* family is an ignored 2xx, 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 a 400. 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/raw and /a/:alias serve published artifacts with ETag/conditional GET, CSP sandboxing, nosniff, noindex, and Basic auth for private publications
    • Diagnostics + lifecycle — GET /health, /ready, /metrics; idempotent stop() that runs shutdown hooks, closes the HTTP server, deregisters from the service registry and settles every connection
  • 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-target enabled toggle
    • Delivery — custom headers merged with defaults, optional HMAC X-Omnislash-Signature, and one keyed dispatch-delivery job per (target, event emission) with BullMQ retry/backoff/DLQ. At least once: a retried or redelivered POST carries the same X-Omnislash-Delivery-Id, stable per (target, event emission) — the receiver dedupes on it
    • Outcome and test — a finished delivery records what it did (delivered, rejected by a non-retryable 4xx, blocked by the outbound URL check, skipped when its target is no longer configured) as its job’s result, which endpoint_get lists beside the failed and queued ones; endpoint_send sends a target a test delivery of its first event’s catalogue sample, marked test: true in the body and X-Omnislash-Test: true
  • Health & Readiness — HealthChecker with uptime tracking, registered per-check gauges, and a small HTTP server exposing /health, /ready and /metrics for 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 a stale flag
  • 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; RateLimitError on rejection
    • Category vs bucket — the category is the low-cardinality Prometheus label, the optional bucket is 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
  • 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 threshold failures (default 3) → half-open after cooldownMs (default 30 s), with key auto-expiry (1 h) and an onStateChange callback
    • Inspector + metrics — current per-provider state readable for the dashboard, circuit_breaker_rejections_total counted
  • Redis Coordination Primitives — withRedisLock + RedisLockHandle.extend() (Lua CAS re-assert of ownership before an irreversible step; false whenever ownership cannot be CONFIRMED, never a throw), with every command the lock issues deadline-bounded — an unreachable Redis fails the acquire with RedisLockUnavailableError instead 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-process SingleFlight, LRUCache and LockService
  • Record & Replay — capture any live session into JSONL and replay it deterministically into a sandboxed Postgres + Redis
    • Zero-overhead taps — five writers (Drizzle logger DML, EventBus.emit, AgentChannel write/state/reset, WorkflowChannel snapshot/log/clear, QueueStatsWriter) call getRecorder()?.write(...), which collapses to a null-check when OMNISLASH_RECORD_DIR is unset
    • Manifest and clock origin — the first process atomically creates manifest.json via O_EXCL; every record’s t is relative to one startedAt, 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-fixture Date sentinel 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; --reset runs once for the composed unit and --loop restarts the whole unit
    • Safety gates — --reset refuses a target matching the primary DATABASE_URL and requires --yes; the player refuses to start with OMNISLASH_RECORD_DIR set (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
  • 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 a code_repos row (slug validated against ^[a-z0-9][a-z0-9-]*$ because it becomes a path segment, installation resolved from the repo owner, default_branch NULL = ask the remote); omnislash git repos lists the attached rows with their sync state plus what the installations can reach, and omnislash 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 inside attachCodeRepo, the single write every entry point goes through, so the CLI, the dev seed and the dashboard cannot each forget one. EXTRA_REPOS survives 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-sync clones 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:/repos is mounted into the code consumers. A private GitHub https:// remote is served per repo by an installation token minted from the deploy’s App (header through GIT_CONFIG_*, never in the URL, and a failed mint only degrades that repo to ambient auth); ~/.ssh + ~/.gitconfig stay mounted read-only for the ssh remotes the App cannot serve. A clone or fetch failure lands in the row’s sync_status/last_error instead 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>, whose origin is the real upstream, so an agent’s git 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, then pull --rebase, each repo under a composed shutdown/timeout AbortSignal
    • Per-repo conflict episodes — a real rebase conflict opens a supervised episode (one ⚠️, a deduplicated repo-conflict-resolve dispatch, 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
  • 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’s env extension, 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 for omnislash.config.ts, and findEnvFile() walking up from the config dir so one workspace-root .env serves several brains
    • Atomic writes — persistent file writes go through atomicWriteFile{,Sync}; direct writeFileSync/writeFile from node:fs is 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 (brain package.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 the doctor_check tool) — ~40 container-safe checks assembled by buildRuntimeCategories() from one input builder, so the page and every agent’s tool run the same checks on the same inputs; RUNTIME_CATEGORY_LABELS is the single source of its category filter — Configuration (the brain’s files and config), Infrastructure (what the deployment reaches) and Evolution (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’s Evolution category (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 check and Human calibration read the database and run wherever it is reachable (the CLI included); Loop lifecycle and Loop failures read 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-named fail) and runs under a 10 s deadline (per-check timeoutMs override) whose AbortSignal checks must forward, so a wedged docker info or a dead SSH host can never stall the report
    • Notable checks — Scheduled work (fail for every enabled scenario or heartbeat whose schedule: never fires — wording outside the scheduler grammar, or a cron with no valid time — which the scheduler only logs; warn when scheduler.enabled: false holds every clock), Chat access (fail for a constitution/access.yaml the bot would refuse — unknown bot, role or command; warn when a role with '*' or one reaching the owner’s memory lines is open to non-owners, when memberOf names a chat where the bot is not an admin, and when engage: always sits 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 tier fails and a degrade warns), Agent memory (warn for a memory block on a scenario’s run-scope agent:, where it is inert, and for a report grant on a role or brain-wide, where it opens every turn), Context marks (warn for notifyAt / compactAt on role-general.md while MANIFEST § context names 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: warn for a declaration that cannot take effect — a flush with nothing to flush into, memory tools or memoryKeep without a memory: block, a block without a memory tool, a leftover promptFlags.memory, a forked copy of a system role whose template declares memory or skills: (an explicit skills: [] in the copy is a deliberate “none” and passes), a connection: MCP server without integration:execute, a skills: name no installed skill answers to, a listed skill that runs a binary (requires.bins / anyBins) on a role without engineering:execute, a skills: list on an API-resolving role without skill:read, a named skills: list with promptFlags.skills: harness / both — fail for the flush curator without a block while flush is on, and the inventory of roles running without memory on every verdict), Brain entities, Hook events against the closed event set (and an inline scenario hook on an event with no run, which never fires), Hook ids (fail for an inline scenario hook and a standalone hook sharing an id — and so its hook: 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 (warn for a role’s contexts: naming no context, a context no role admits and no run reaches, ask without description, a view two contexts claim, a data: view or by column public lacks — the runtime reads the views, a laptop notes it did not), MTProto hook accounts (a hook on mtproto:message.* no authorized Telegram account can feed), Capability names (fail for every capability name a role file or a grants: block spells that the catalog does not know — with the file, and the rename it went through or the nearest real name), Blueprints (fail for every workflow scenario’s .blueprint.yml a 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.ts imported under the discovery rules), Pipeline build (runtime: the pipelines endpoint’s own verdict on its newest build)
    • Provider liveness is read, never probed — LLM providers and Memory embeddings grade 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 own models.json topology — every step of a role blocked is fail, a surviving fallback is warn — 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), and Memory embeddings never fails because the memory layer’s own table says the keyword and path legs still answer. omnislash doctor on a laptop reads no circuit record and the checks say liveness not observed from here on a green line rather than turning permanently yellow. Memory embeddings lives under Infrastructure in the runtime preset for the same reason Credentials does — what it grades is a live-system fact
  • 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 dev the app runs on the host and registers over host.docker.internal via a static extra_hosts mapping
    • Introspection client — packages/infra ships 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
  • Stable Error Codes — machine-readable error taxonomies for both surfaces: OmnislashError (code + hint + toJSON()) for CLI and config, and AppError/AgentError/ToolError/ServiceError/AuthError/DatabaseError with typed codes across the runtime
  • Graceful Shutdown & Signals — registerProcessSignals gives every app one shutdown path, and isTsxHotReload distinguishes a watch-mode SIGTERM from a real stop
  • Dead Code Detection — knip with a knip:gate CI 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_records per 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.cancel as the person who tapped), a run card’s cancel / pause / resume / retry / recover / kill in Telegram (run.<verb>, as the presser), a parent agent’s agent_answer to 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 (window or from / to / tz, default all: the log is never hidden by a default window) and q (a part of the summary, the op or the target id; since 1.24.0), paged by passing nextCursor back as cursor; the answer carries the resolved window
    • 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 managed postgres-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 on probe_success instead of up
    • cAdvisor — per-container CPU/memory/start-time, with per-service restart counting feeding restartsLastHour and the container-restart-loop alert, and CPU used against each container’s cpus ceiling feeding the container-cpu-at-limit alert (over 90% for 15m); the restart count folds cAdvisor’s churning container labels away first, via max by (service, id) under the changes(). It reaches BOTH daemon sockets — docker.sock as 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 (any unattended-upgrades glibc 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 Blind asserts the positive (count(container_last_seen{name!=""}) > 0), cAdvisor Coverage Drop asserts 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.cadvisor answers “is it collecting” rather than “did the scrape connect”, and a service that is up with no restartsLastHour while others have one is named in health_overview.caveats instead of reading as observed-and-quiet
    • Runs in every mode — Prometheus, node-exporter and cAdvisor come up in dev, start and prod with mode-specific scrape configs (host-side ports with ${…Port} substitution in dev, Docker DNS in start) and lighter retention/memory in dev; PROMETHEUS_URL tells apps where to reach it
  • 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 in ToolsGateway.execute()
    • Workflow — runs/tasks totals and durations, workflow_awaiting_duration_seconds, workflow_active_runs, workflow_task_recoveries_total, workflow_queue_wait_seconds and 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, plus brain_deploy_drift_seconds behind the Deploy Drift alert: how long main has 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 — registerHealthMetrics exposes app_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
  • Health & Readiness Endpoints — createHealthServer(checker, port, log, registry) exposes /health, /ready and /metrics on one port for bot, worker, scheduler, mcp, pipelines and brain-sync; webhooks and dashboard expose the same three paths as Hono routes; HealthChecker composes 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 (monitoring app) — web UI over every BullMQ queue, on its own Express server with /health, /ready and a /metrics endpoint 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} from QueueEvents, and bullmq_last_failed_timestamp_seconds so 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), and bullmq_jobs_failed_total{queue} counting jobs as they fail for good — the Failed Jobs alert fires on more than 10 of those in an hour, so it resolves on its own once failures stop
  • 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 critical and warning groups, routed to Telegram and an engine webhook, all config-as-code (rules.yml / contactpoints.yml / policies.yml)
    • Rule set — a *-down rule 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, and Pipeline 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_DEPS maps a rule uid to its backing Compose service; the CLI drops the rule when the service is absent and tombstones it under deleteRules, in lockstep with the Prometheus scrape-job filter
    • Credential rendering — contactpoints.yml ships __VAR__ placeholders that deploy.sh renders to literals before compose up (the bot token’s names the chief’s bot’s tokenEnv, 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 at 1m / 30m / 12h, grouped by alertname; every severity fans out to both the human contact point and the resolver webhook, with continue: true cascading the alert to the resolver route
  • Alert Auto-Remediation — the engine-webhook contact point POSTs every alert, firing and resolved, to POST /event/grafana with a bearer secret, and it flows through the generic ingest pipeline (brain extract() → omnislash.external_events upsert, which is the dedup → route job → ingest:event hook → agent + disaster-recovery skill)
    • What the agent may do — an idempotent auto-fix, a gh pr create, or a HITL escalation; the route’s repeat_interval sets the re-summon rate and the brain’s idempotency key collapses duplicate POSTs
  • 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 level as a low-cardinality label and attaches module, msg, runId, agentId, pipelineRunId, taskRunId as structured metadata — queryable via LogQL without exploding the TSDB
  • Structured Logging — createLogger(module) on Pino everywhere; single-line pino-pretty with 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 logging info only when a tick actually changed something)
  • Sentry Error Tracking — initialised per app in instrument.ts loaded via --import before any other code (bot, worker, scheduler, mcp, pipelines, brain-sync); no-op when SENTRY_DSN is unset, Sentry.pinoIntegration auto-captures error/fatal records, tracing off, and dataCollection explicitly collects nothing (no cookies, HTTP bodies, GenAI prompts or DB queries)
  • health_overview Tool — the health.overview op (since 1.23.0, typed outputSchema): worst (failing > attention > ok), the process’s health probes, the service roster (up while 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 }; sources says whether Prometheus and the service registry answered, Loki/Grafana degrade gracefully, and whatever could not be observed is flagged in caveats
  • infrastructure_endpoints Tool — returns every configured infrastructure endpoint (Prometheus, Loki, Grafana, databases, queues, …) so an agent can discover where a service lives before querying it; restricted risk behind the infrastructure:discover capability
  • 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 through inspectCircuitBreakers() / inspectRateLimits() / discoverCircuitBreakerProviders(), answering “is the circuit open right now?” rather than the rate-of-rejections counter Grafana shows; the snapshot refreshes on the models topic and polls every 5s (a rate-limit window fills and resets with no event), the event feed refreshes on models (every event it lists rings it), cooldowns tick client-side
  • Credential Inventory — one CredentialRecord per 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 snapshot pipelines publishes), describeConnectionCredentials, describeEnvSecretCredentials, describeDeployCredentials
    • Seven statuses, unverified apart from unknown — ok, expiring, expired, invalid, missing, unverified (no probe attempted), unknown (a probe was needed and could not run)
    • One Credentials doctor check — a single roll-up over the whole inventory rather than a check per row, fed by a required RuntimeDoctorInput.credentials thunk so an unreachable GitHub costs one warn instead 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 as expiring, engine default 14
  • 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 the usage_* tools (usage.summary / breakdown / trend / heatmap / tools, which Audit › Cost (/audit/cost), Home’s spend vitals and the run-detail usage tab bind), /stats in chat, the per-run cost column and every eval cost_usd; metered and subscription dollars are always reported apart rather than summed, and a segment with no billing field 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 by timedSegments so an expired question cannot print an average larger than any turn that ran
  • Monitoring Network — a dedicated monitoring Docker 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, *-down alert rule)

CI/CD

  • No per-push CI — the engine repo ships no ci.yml: local lefthook is the pre-merge gate, Smoke → gates runs the static suite once a week against the tip, and Release runs it in full before publishing unless Smoke → gates already passed on that exact commit
    • The suite both jobs run — pnpm turbo type-check lint test with no --affected, on ubuntu-latest with no persistent cache, followed by the checks below
    • Repo-wide type-aware lint — pnpm lint:full runs nursery/noFloatingPromises over everything including scripts/, test/ and root configs; the local gate deliberately skips it to stay at ~12s
    • Prod-import check — pnpm check:prod-imports guards what production code is allowed to import
    • Publish-manifest check — pnpm check:publish-exports diffs exports against publishConfig.exports, drift in which is invisible to the engine and only breaks brains
    • Knip gate — pnpm knip:gate fails 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
  • Release Pipeline — workflow_dispatch only, refused off main, never fired by a push or a tag; changesets accumulate on main and 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-commit chore: release v<version> [skip ci]
    • Publish to GitHub Packages — pnpm publish -r against npm.pkg.github.com with a scoped .npmrc written by setup-node in CI, deliberately not committed at the repo root. No --access flag: it would OVERRIDE each package’s own publishConfig.access, silently publishing a package that declares itself public as restricted
    • Docker build & push — linux/amd64 image pushed to ghcr.io/omnislash-ai/engine:<version> and :latest on a GitHub-hosted BuildKit builder backed by the GitHub Actions layer cache, with a LATEST_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 through git 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-error docs-sync job (its own workflow, called by every release and dispatchable for one version) rsyncs docs/brain-dev/*.md plus an .engine-version stamp into omnislash-ai/website’s src/content/docs/ under a website-only PAT, alerting Telegram on failure
  • 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-run listing instead of deleting
    • --protect exempts 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 fails pnpm install --frozen-lockfile with ERR_PNPM_FETCH_404
  • Changeset Bump Workflow — a workflow_dispatch form (description + patch/minor/major) that runs the changeset helper, pushes a branch and opens the PR through gh, 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 changed job compares github.sha against the last completed run’s head_sha and drops the scheduled tick on a match; a workflow_dispatch always 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-example as BRAIN_ROOT, hard-failing on ERR_MODULE_NOT_FOUND / Cannot find package|module in 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.ts set 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
  • Generated Brain Deploy Workflow — the engine renders a service-agnostic .github/workflows/deploy.yml into brain repos: run the brain gates, resolve the engine version from the @omnislash-ai/cli peer 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.sh
    • paths-ignore carries 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 in brainSync.runtimeOnly (an Obsidian corpus, people/, agent reports — G-directories the engine never loads, so the .html/.json an agent drops there is live the moment brain-sync writes it). The same list drives the Deploy Drift alert, 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.yml runs (one renderer, gates-job.ts), and deploy needs both it and build in the same run, so the SHA that was gated is the SHA that was built and deployed; a manual workflow_dispatch is gated the same way, and nothing depends on ci.yml existing or finishing. build runs 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 what Deploy Drift reports. An owned (.override) deploy.yml must 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=value block, never on its command line: that line is argv of a real process on the server and ps shows argv to every local account for the whole deploy. No ${{ }} expression is spliced into a run: script either, and DEPLOY_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 run omnislash upgrade instead of deploying half-configured
    • The host is pinned, never scanned — Setup SSH writes known_hosts from DEPLOY_HOST and the DEPLOY_HOST_KEY repository variable (the host’s own /etc/ssh/ssh_host_ed25519_key.pub, read off the host by omnislash init-server and set alongside DEPLOY_HOST) and runs with StrictHostKeyChecking yes; a fresh runner per deploy had made ssh-keyscan a 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-server prints the key’s fingerprint so the first contact can be checked against the provider console
  • CI Workflow — the engine renders .github/workflows/ci.yml into brain repos: one gates job (shared with deploy.yml) that installs with the same registry auth and runs the brain’s own type-check / lint / test scripts between two engine-owned steps — Upgrade task first (fails while upgrade/TASK.md has an unticked before-deploy or in-repo entry), omnislash doctor --check pipelines last — on pull requests into main, on a daily schedule and on manual dispatch. No push trigger — Deploy runs the same gates on every push to main, so a push run would gate the same SHA twice — and no paths-ignore: a skipped workflow reports no status and would hang a prose-only PR behind a required check. Config-blind: nothing in it derives from omnislash.config.ts. The scheduled tick re-runs the gates against a tip nobody pushed — what catches main going red from a dependency or registry change rather than from a commit. Built by ci-yml-builder.ts, sentinel ci.yml.override

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