ADR-0078 — The Construct: one backbone for all automation + a role-split Agent Smith
Status: Accepted — Phases 1–3 implemented and live (manifest config/automations.toml + notify layer + AutomationRun producers + Status program); Phase 4 in progress
Date: 2026-07-04
Owner: Gera
Supersedes/extends: docs/planning/automation-run-backbone.md (its follow-ups become this ADR's Phase 2–4)
1. Context — what's actually true today
Everything that runs on a schedule, fires an alert, posts to Slack, or lets Agent
Smith do work is real and working — but it's scattered across two runtimes with
no shared data plane, and the pieces meant to unify it are built-but-unwired.
The concrete fragmentation (verified against origin/main):
- Three
chat.postMessageimplementations.agent_smith/slack.py(Python, canonical, appliesto_slack_mrkdwn),bake-alert.ts::postBakeAlertToSlack(TSfetch, for Lambda/Vercel), and~/.claude/scripts/whatsapp-doc-mirror/ slackpost.py(a third stdlib copy for#transcripts). Same message, three code paths, three formatting behaviors. - Channel routing scattered across ≥5 places:
SLACK_AGENT_SMITH_CHANNEL/SLACK_MARKETING_CHANNELinconfig.py,#tickets-*intrello/taxonomy.py,#transcriptshardcoded in whatsapp,DEFAULT_SLACK_CHANNELinbake-alert.ts, and achannels.tomlthat the TS admin route reads but which isn't committed. - The AutomationRun backbone has zero producers.
recordAutomationRun/scripts/automation-emit.ts/defineDetectorare fully built and tested, but nothing in prod calls them. The/admin/dev/agent-smithJobs tab reads an empty table. It's a finished shelf with nothing on it. - Two non-overlapping run-visibility systems. DDB
AutomationRun(Jobs tab) vs. live Temporal introspection (Activity/Run tabs). A launchd cron appears in neither automatically; a Temporal workflow appears only in the latter. - Two schedule authorities reconciled by hand: launchd plists (mini) +
Temporal Schedules (
propflow-smith), stitched together only inregistry.json. - Alerting is bolted, not layered:
sendBakeAlert= email + a bespoke Slack post, separate again from every Python-side#agent-smithpost. No single "notify ops" primitive that both runtimes share. - No task-execution abstraction. "Smith does work" is ad-hoc free-text in
Slack → one
claude_runner(claude -p). The structured task surface (per-person canvas + Trello) only files cards for humans — there is no path that hands Smith a defined job to execute and records it. - A monolithic brain. Scheduled reporting, alerting, and deep investigation
all funnel through the same reply workflow + single
claude_runner. No planner / worker / alert / status separation → context bloat and no way to run cheap read-only work without spinning the full investigation brain.
The through-line: there is no Construct — no shared spine. Each capability re-implements run, log, post, alert, and schedule. Adding an automation means re-deriving all five. Giving Smith a new job means a bespoke integration. Updating "how Smith talks" means editing 3–5 places.
2. Decision
Build one automation backbone — the Construct — that both runtimes and every
Agent Smith role plug into, and express Agent Smith as a small set of role-scoped
programs loaded in the Construct. Five primitives + a role
model. Nothing is bespoke anymore; adding a capability is "declare a manifest,
write a run()," and it automatically gets logging, alerting, cataloguing, and
clean Slack — in either runtime.
Naming (the vocabulary we'll use): the Construct = this backbone (the Matrix loading program — where you gear up and load anything you need before a run). A program = any capability loaded into it (a cron, a detector, a Smith role). The Operator = the Construct's
notify/dispatch layer (Link at the board — sees every run, routes every message). So: "add it to the Construct", "it's a program in the Construct", "the Operator posted the digest."
The Construct — five primitives
P1 — Job Manifest (the catalogue / the map). One declarative entry per
automation, regardless of runtime (launchd / Temporal / Vercel-cron / ad-hoc /
webhook): { id, kind, runtime, owner, trigger, channel, alerting, description }.
This is the single house. It is the source of truth that BOTH the jobs CLI AND
/admin/dev/agent-smith read — replacing the hand-reconciled registry.json +
launchd/Temporal split. A manifest is also the map Smith reads to know what a
job does, where it posts, and how it's triggered (see §3).
P2 — Run Lifecycle (withRun). One wrapper per runtime — withAutomationRun
(TS) / with_automation_run (Python) — that every automation calls: records
running → executes → records the AutomationRun (status / summary / details /
timing / host) → on failure fires the alert (P4) → marks the run digest-eligible
(P3). Logging + alerting + cataloguing stop being hand-wired; they're a
consequence of running through the wrapper. This gives the P3 backbone its
missing producers (fragmentation #3) in one stroke.
P3 — Notify (the one Slack layer). ONE typed primitive — notify({ channel, kind, title, table?/blocks?, thread? }) — with exactly ONE thin implementation
per runtime over the SAME committed channel config. Kills the three
chat.postMessage copies (#1). Channel routing = one committed channels.toml
read by both runtimes (#2). Formatting helpers are shared (the Block-Kit
markdown→native-table rendering we just proved for the turnover scorecard
becomes the standard). "Global update to how Smith talks" = edit one layer.
Digests are a P3 concern: the Status program reads P2 runs and posts one
consolidated update per channel instead of N scattered per-cron messages.
P4 — Health/Alert flow (detectors). defineDetector already records a run
AND fans a finding to sendBakeAlert. Make it the ONE way a health check is
expressed; migrate the existing sendBakeAlert call sites and the launchd health
crons (spine-health, pms-sync-canary, latency, drift) onto detectors. sendBakeAlert
becomes notify(kind: alert) + email under the hood. Health = detectors in the
Construct: visible on the page, alerting through the one Slack layer (#4, #6).
P5 — One admin surface. Because every job has a manifest (P1), emits runs
(P2), and declares a channel (P3), /admin/dev/agent-smith collapses into a
single pane: Catalogue (manifests) × Runs (AutomationRun history) ×
Activity (Temporal deep-dive for the workflow-backed ones) × Channels ×
Health (detector status). The manifest is the join key that ties a job's
AutomationRun history to its Temporal introspection — ending the two-disjoint-
systems problem (#2).
The programs — Agent Smith as roles in the Construct
Split Smith by ROLE, not by channel. Channel is a notify parameter; role is
an identity that determines tools, permissions, and context. Each role is a
program in the Construct — a fresh, bounded invocation whose context is scoped
to its job; it reads the Construct (manifest + relevant recent runs) for the map
instead of carrying it. That is
the context-bloat fix, and it makes "Smith already knows how/where" fall out for
free: the manifest is the map.
| Role | Reads | Writes | Brain? |
|---|---|---|---|
| Smith Status | AutomationRun runs + channels | digests via notify |
none / small |
| Smith Alert | detector signals | alerts via notify + email |
none |
| Smith Planner | a structured task + manifests | a scoped work order (a job) | small |
| Smith Worker | a work order + its manifest | code / PR / data-fix, recorded as a run | full (claude_runner) |
- Status and Alert are cheap, read-only-ish, and replace the scattered per-cron posts + the bolted alert path.
- Planner → Worker is the new task-execution path (fragmentation #7):
extend the tasks canvas from "file a Trello card for a human" to optionally
"file a work order for Smith" → Planner scopes it → Worker executes it
(bounded to the work order's scope, PR-gated by the existing
SmithApprovalWorkflow- merge allowlist) → the run is recorded like any other automation.
- Today's monolithic
claude_runneris the Worker; the split just formalizes the other three as separate, cheaper agents so investigation context isn't polluted by status/alert/planning concerns (#8).
Answering the open question directly (subagents vs sessions-per-channel vs daily-context): roles as subagents, spawned fresh per task; channel is a parameter, not an identity; no accumulating daily context. Each role run reads the Construct for what it needs — the Construct is the memory, the program's context stays lean. Per-channel identity would multiply agents without bounding context; role identity bounds capability and context, which is what we actually want.
3. Why this shape (the load-bearing choices)
- Data-plane Construct, not a shared library. The two runtimes (TS + Python)
can't share code, so the Construct is DDB (
AutomationRun) + Slack (notify) + a committed manifest/channel config — thin single-source adapters per runtime speak to the same data plane. This is the only design that unifies across the runtime split instead of picking one. - The manifest is the map. Self-describing system → Smith reads the catalogue
to know how/where, instead of humans encoding it into each agent's prompt. A new
program = a new manifest +
run(); Smith plugs in, it doesn't get rebuilt. - Parallelism + global update fall out. Fan-out = iterate the Construct's
manifests. "Global Smith update" = one
notifylayer (the Operator) / onewithRunwrapper to change. Making things parallel = the backbone already isolates each run. - Additive, not a rewrite. Every primitive wraps or routes existing calls; no behavior is duplicated (respects the repo's one-source-of-truth rule). We delete the duplicate posters as they're migrated, never run two in parallel.
4. Phasing (non-big-bang; each phase ships value alone)
- Phase 1 — Notify consolidation (highest value, lowest risk). Commit one
channels.toml; build the singlenotifyprimitive per runtime; migrate the threechat.postMessagecopies +sendBakeAlert's Slack half onto it; adopt the shared table formatter. Outcome: Slack stops being messy; one place to change how Smith talks. This is the pain you're feeling — do it first. - Phase 2 — Emit everywhere. Ship
withRun; wire every launchd cron + Temporal workflow + the turnover-eval writer to emitAutomationRun; migrate the health crons todefineDetector. Outcome: the Jobs tab is real; the catalogue is complete; the registry audit we just did becomes live-backed. - Phase 3 — Manifest as source of truth + Status role. Unify
jobsCLI + admin page onto the one manifest schema across all runtimes; add the Status program (the "one 8am writer" done right, reading the Construct). Outcome: one house, one pane, consolidated digests. - Phase 4 — Smith roles + task execution. Formalize Planner/Worker/Alert/Status; add the structured task→work-order→Worker path — all programs in the Construct. Outcome: Smith is a clean engineer with full scope, bounded context, that plugs into the flows.
5. Consequences
Positive: one place to add / track / update; logging + alerting + cataloguing are free; Slack is clean and centrally controlled; the admin page becomes the true single view; Smith gains a structured way to do work with lean context; parallel work and global updates are trivial.
Costs / risks: a data-plane Construct spanning two runtimes needs two thin adapters kept in lockstep (mitigated: they're small and share the manifest/channel config + a drift test). Migrating the posters must be strictly one-at-a-time with the old path deleted in the same change (no parallel Slack impls). The role split adds orchestration surface — start with Status/Alert (cheap, read-only) before Planner/Worker.
6. Alternatives considered
- Do nothing / wire crons ad-hoc to
automation-emit. Fills the Jobs tab but leaves 3 Slack impls, scattered channels, and no task-execution path. Rejected — treats the symptom (#3) not the disease (no Construct). - Pick one runtime. Rewriting all Python automation in TS (or vice-versa) is a massive rewrite that violates one-source-of-truth mid-flight. Rejected — the data-plane Construct unifies without a rewrite.
- Per-channel Smith sessions. Multiplies agents, doesn't bound context by capability, and conflates routing with identity. Rejected in favor of roles.
7. Steering decisions (Gera, 2026-07-04)
- Phasing order — MANIFEST FIRST. The catalogue (the P1 manifest schema) is framed and committed before any Slack work; the Operator (notify consolidation) builds against the settled schema. This reorders §4's phasing: manifest + channel registry land first, then the Operator, then emit-everywhere, then roles.
- Manifest home — committed repo file.
config/automations.toml+config/channels.tomlat the repo root — reviewable in PRs, shared by the mini + Vercel + both runtimes.~/.claude/jobs/registry.jsonis folded in and retired once thejobsCLI reads the manifest (its live plist/Temporal reconciliation stays; only the hand-kept annotations move here). - Worker scope — includes the Construct. Smith Worker may execute PRs
against the automation backbone itself (self-modifying), gated by the same
SmithApprovalWorkflow+ merge allowlist as any other repo. - Role mechanism — ONE mechanism. All four roles (Status / Alert /
Planner / Worker) run as Temporal workflows; where a role needs a brain,
the workflow's activities spawn
claude_runner(the shape SmithReplyWorkflow already proves). One operational surface, one visibility story.