Design proposal · 2026-08-05 · nothing here is built

01

Agents on Temporal?

Your question: "Add Smith's sessions to AgentFlow, source agent-smith, and talk to them. They're on Temporal — should the operators and the architect be on Temporal too?" The short answer, argued over the next eleven slides:

SMITH → FEEDYes, first. Smith rows merge into /agents through the seam #5409 already built. "Talking to Smith" is a Temporal signal — the same door its Slack messages use — never a keystroke.
OPERATOR → YESMove it. The Operator's whole job is durable waiting, and roughly two-thirds of the Supervisor exists to fake durability for it with tmux forensics. Temporal deletes that machinery instead of maintaining it.
WORKER → NOIt stays in tmux. The Worker IS an interactive Claude session; its terminal is the product surface /agents streams. There is nothing durable to hold — its transcript is already the record.
ARCHITECT → NOT YETLeast broken, hardest to move. Event-woken by design, no loop to hold, and its value is accumulated working context — the one thing Temporal cannot persist. Revisit after the Operator pilot.

Every mechanism named in this deck was read from source this week — the Supervisor (agentflow/supervisor/src), the relay, Smith's workflows (agent-smith/src/agent_smith), the Temporal house rules (propflowai/src/lib/temporal/README.md), and the shipped /agents surface. Where this deck disagrees with the first instinct that prompted it, it says so out loud (slide 09).

The decision, by each role's real failure mode

02

Who moves, who stays — judged by how each one actually fails

MOVE — YES

Operator

Fails by dying with its pane. Today: a nudge watcher that leaves orphans (two found 2026-08-04, pid files gone, invisible to nudge --stop), a Supervisor that respawns successors up to 5 times, and blocks that must be adopted from dead owners' ledgers. All of that is engineering around ephemerality.

STAYS — NO

Worker

Fails by working on the wrong thing, not by dying — and when it does die, the loss is visible in the streamed terminal. #5411/#5412 just made that pane the centrepiece of the session page. Workflow-izing it would delete the product surface to durably hold a thing that needs judgment, not uptime.

NOT YET

Architect

Already event-woken, never looping (mail-wake, decision hook, escalations, CI webhook — its own skill forbids a timer). Its asset is a large working context; Temporal persists state machines, not model context. It is also the least broken thing in the fleet — migrate last, if ever.

INTO THE FEED

Agent Smith

Already Temporal-native and already durable — it needs no operator machinery, it needs visibility. It is invisible to AgentFlow today (zero references in either direction). The feed work is a read-side projection plus one signal path; no Smith redesign.

One sentence for the whole page: Temporal is for the roles whose job is to outlive things — the Operator outlives the Worker's optimism, Smith's approval gate outlives a 24-hour human pause. It is not for the roles whose job is to be watched working.

agentflow/supervisor — read line by line

03

The Supervisor is mostly a workaround, measured

Of its ~1,400 lines, roughly two-thirds answer one question: "does that process still exist, and can I get bytes into it?" That is the tax on running a durable role in an ephemeral body:

Machinery (file)What it doesWhy it exists
Liveness (alive.ts, 149 lines)Tri-state "alive?" — roster pid plus tmux pane ancestry walk, 64 hopsToo permissive → a task silently loses its Operator; too strict → two Operators steering one Worker. Both have happened.
Respawn (index.ts)2-sweep dead-streak debounce, successor mint, MAX_RESPAWNS=5, auto-park at the capOperators die with panes; something must notice and re-create the judge — then bound the loop when respawning itself fails.
Spawning (spawn.ts, 178 lines)tmux boot, readiness poll to 75s, kickoff typed as keystrokes, --permission-mode auto or it hangs foreverA fixed sleep once minted a successor with an empty prompt and zero tokens — registered, alive, and never going to do anything.
Mail rescue + mail-wakeRe-route inbox files whose owner is provably dead; type at idle sessions holding unread mail, with 2-min/10-min debouncesFour messages were stranded in dead sessions' inboxes on 2026-08-03. Delivery is a file plus a keystroke plus a submit check — three mechanisms for one signal.
Auto-compact (compact.ts)Poll ctx, ≥80% + idle → deliver /compact with a derived keep-list, 15-min marker debounceIntrinsic to LLM sessions, not to ephemerality — this one survives any executor. But for a Temporal operator it becomes moot: each judgment starts fresh (slide 06).

The intrinsic residue is small and clean: task.json as the authored contract, the terminal-state deny-list, events.jsonl as an append-only timeline, routing mail to a task's current owner, and a context ceiling watched from outside. Everything in that residue survives this proposal untouched — and events.jsonl is, precisely, a hand-rolled best-effort version of what a workflow history provides by construction.

Nothing new is invented — each hack maps to a primitive

04

Every wake source the Operator has maps to a primitive Smith already uses

Operator today (skill §1)Mechanism todayAs a workflow
The loop itselfnudge --idle 600 — a poll that must be remembered, armed, and ended; orphans outlive their pid filesThe workflow IS the loop. It completes when the task closes; an orphaned loop becomes unrepresentable.
Quiet stop (worker silent 5 min)Digester notices, nudge fires on idleSignal workerStopped from the Digester (a 5-line Temporal client call in the relay)
Decision answerWebhook → wake-session types into the pane; exit codes 3/4/5 for the ways typing failsSignal blockAnswered(blockId) — no pane, no typing, no exit-code taxonomy
PR / CI verdictReview webhook → keystrokesSignal verdictArrived(pr, verdict)
Heartbeat, 60–90 minThe same nudge pollDurable timerwait_condition(…, timeout=90m), survives reboot, exactly Smith's SmithApprovalWorkflow shape (24h human gate as a first-class timer)
Death & successionSupervisor revive ×5; successor must blocked adopt the dead owner's questionsDeleted. The workflow id owns the task's blocks for the task's whole life; a worker-process restart replays into the same execution.
Steering the Workermsg <sid> — park or typeUnchanged: an activity on the mini shells the same msg. Temporal makes the caller immortal; it does not make typing into a pane less fragile (slide 09 is honest about this).

Precedents, all live in agent-smith today: start-or-signal dispatch (dispatcher.py:114-160), durable human gate (approval.py, 24h timeout + poll ceilings), and the smallest one — SmithWorkClaimWorkflow, a workflow whose id is the claim and whose lifetime is the work, parked in wait_condition until released. The Operator is that third shape with a brain attached.

Proposed spec — names follow the house naming standard

05

The Operator as a workflow, concretely

Identity & home

  • Workflow TaskOperationWorkflow, id task-operation-<task-slug>domain-named, never persona, per the one rule in src/lib/temporal/README.md ("Agent Smith" keeps the only persona exception).
  • Queue propflow-agent-tasks, exported constant, registered by the existing agent-smith Python worker on the mini (co.propflow.smith-worker) — the activities need the mini's disk and tmux, and that mini-bound Temporal runtime already exists.
  • Namespace propflow-tools-prod — same runtime, same trust boundary, and co-location matters: cross-namespace workflows cannot signal each other.
  • Input: the task slug. Nothing else. task.json stays the authored contract on disk; the criterion is read inside activities, so history stays ids-only (ADR-0026 — history is plaintext at rest; the scratch-row pattern is the precedent if a judgment ever needs to carry tenant text).

The loop body

  • Wait: wait_condition(pending-signal, timeout = 90 min). Signals: workerStopped · blockAnswered · verdictArrived · mailArrived · steered — payloads carry eventId + ids/refs only (block id, PR number, inbox filename), never message text.
  • Judge: one activity, judge_task_activity(slug) — a claude -p run on the Operator skill, exactly Smith's claude_runner pattern: sync def, heartbeat per tool_use, RetryPolicy(maximum_attempts=1) because judging is side-effectful (it steers, raises, closes). It reads task.json / STATE.md / events.jsonl from local disk, acts through the existing CLIs (msg, blocked, task-event), and returns an enum: done / drifting / hung / blocked / waiting.
  • Route: workflow code branches on the enum only. Terminal state → append closed, complete. Otherwise loop; continue-as-new at the idle point (Smith's belt: a 2,098-event history once cost a 34s cold replay that blew the task timeout).

What changes for you: nothing about how a task looks. Same task.json, same events.jsonl timeline on /agents, same decision dock. What disappears is the failure class where the judge was dead and nobody knew — an answered block now signals a workflow that cannot not exist.

The design move the determinism objection forces — and it's a good one

06

Skeleton on Temporal. Judgment in the skill. Never blur the line.

WORKFLOW — deterministic, tiny wait (signal | 90-min timer) → run judge activity → branch on an enum → loop · continue-as-new · complete changes rarely ⇒ the workflow.patched() tax stays near zero REPLAY-SAFE BY CONSTRUCTION ACTIVITY — all the non-determinism claude -p on the operator SKILL.md reads task.json · STATE.md · events.jsonl (local disk) acts via msg · blocked raise · task-event heartbeats per tool_use · one attempt, never blind-retried changes daily ⇒ edit prose, ship instantly NEVER REPLAYED — RESULTS ARE HISTORY

Why this split is the whole ballgame: Temporal replay demands determinism, and an LLM call is the least deterministic thing there is — but replay never re-executes an activity; it replays the recorded result. Smith has run its entire brain this way for months. The residual risk is not determinism, it is iteration speed: Smith's reply workflow carries seven live workflow.patched() gates because its command sequence keeps changing. The Operator avoids that fate only if the workflow stays this dumb — every behavior change must land in the skill file, never in the loop. That is a discipline, and it needs to be written down as a rule of the pilot.

The Supervisor after the cutover — smaller, not gone

07

What dies if this lands — and what must stay

Retired (operator-side)

  • All of operator liveness + respawnalive.ts's ancestry walks, the dead-streak debounce, MAX_RESPAWNS, auto-park-at-cap, operatorRespawns in task.json.
  • Operator spawning — the tmux boot, the 75s readiness poll, kickoff-as-keystrokes, the sid-discovered-after-start dance (sidForTmuxSession). A workflow's id is chosen before it runs; the whole "wait for the process to name itself" class vanishes.
  • Block adoption (operator skill §4b) — blocked adopt exists because answers route to ledgers of sessions that die. A workflow-owned block has one owner for the task's life.
  • Orphaned-mail rescue and mail-wake, for operators — mail becomes mailArrived; there is no idle window to respect and no pane to type at.
  • Operator nudge watchers — and with them the orphan-watcher failure class (pid files gone, processes running, invisible to nudge --stop).
  • Operator auto-compact — each judgment is a fresh bounded run; there is no months-old context to hit 80%. The "operator compacts away its own criterion" failure mode becomes unrepresentable.

Stays, on purpose

  • The Digester — STATE.md is still how a judge reads a Worker cheaply; it just also fires a workerStopped signal.
  • Worker spawn, worker compaction, worker mail-wake — the Worker stays in tmux, so its babysitting stays. The compact sweep that saved a session at 81% yesterday keeps running.
  • Intake validation + first-mint — someone still validates a malformed task.json and starts things; "mint an Operator" just becomes start_workflow (one line, idempotent by id) instead of a two-boot, ~80s tmux ceremony — which also deletes the "one intake per sweep" throughput cap the intake branch had to impose.
  • task.json + events.jsonl — unchanged, deliberately. The workflow history is an engine internal; events.jsonl stays the human timeline /agents renders, written by the same task-event CLI from inside the judge.
  • The relay — untouched. It gains one small client: the Digester and the two webhooks send signals instead of keystrokes.

Net: the Supervisor shrinks from "fake durability for the judge, plus chores" to "watch the tmux Workers, validate intake, keep STATE.md fresh." Every retired line is a line that has already paged you at least once.

source: agent-smith — the seam exists; two facts to respect

08

Smith in the feed: a projection, not a port

What you'll see: Smith's live drives as rows on /agents next to the fleet — source-badged agent-smith, each opening a session page with its conversation timeline where a tmux session shows its terminal.

QuestionAnswerGrounding
What is a Smith "session"?A workflow execution. One row per live drive — smith-reply-<channel>-<thread>, approvals, fan-out tasks. Its status() query already exposes a live phase (classifying / idle / done) that maps onto the feed's busy/idle dots.reply.py:562; ids in agent_ident.py:39-46
Where do rows come from?A getSmithSessions() fetcher beside getRoster() in the overview route — the app already lists Smith's namespace with the SMITH_TEMPORAL_* creds on the admin page, and already decodes a run into typed events. This is promotion, not invention.overview/route.ts:92; admin/agent-smith/{activity,run}/route.ts
What does the timeline show?The conversation log — Smith's append-only per-channel JSONL already stamps every in/out row with its workflow_id, and outbound_rows_since() is a ready-made tail. History decode is the fallback for non-chat drives.conversation_log.py:25-38, 125-155
Is source the right field?Yes, with one honest amendment. The field was built per-roster-source, stamped per-session because "the aggregation seam is a merged sessions[]". But its shipped meaning is machine ("Gera's mini"); Smith adds a second axis. So: source: "agent-smith" as the producer label, plus a sessionKind: tmux | workflow discriminator so no component ever infers kind from a display string.types.ts:36-41; relay commit 82d1513

⚠️ One blocking prerequisite found while reading: the relay half of the source field (82d1513, branch jg/relay-startedat-and-source / agentflow-relay#11) is not merged and not deployed — the live roster serves no source key at all. The app half (#5409) is merged and renders "—". Landing that relay PR is stage 0 of everything on this slide.

The write path, and five tmux assumptions a workflow row breaks

09

Talk to Smith = signal the workflow. And five assumptions to unlearn.

The write path

  • Never keystrokes. Smith has no pane. The composer posts to a new POST /api/agents/smith/message which does signal-with-start on SmithReplyWorkflow — byte-for-byte the contract of dispatcher.dispatch() (start; on already-started, signal add_context), the same door every Slack message walks through. scripts/message_smith.py is the working precedent.
  • Replies land in Slack too — Smith's product surface is the channel, so a turn injected from /agents posts its answer there and the feed timeline mirrors it. That's a feature: no second conversation store.
  • Honest caveat from the precedent: injection only enqueues — if the mini's worker daemon is down, the turn sits queued until it returns. The row should say "queued", not pretend delivery.
  • Two deliberate amendments: (1) read-only.test.ts is a tested invariant — it gets amended by name, not worked around. (2) Today the dispatcher writes the inbound conversation-log row; a second door should move that append inside the workflow (one patched activity) so every door logs uniformly.

What /agents must not assume

  • Terminal ≠ universal. The terminal card renders unconditionally today; pane-less shows "Not in a tmux pane" — a missing terminal. A workflow row needs the panel swapped for the conversation timeline, not an apology.
  • The limbo page lies to Smith. The no-row state promises "appears within about 30 seconds" — a claim about the relay's roster refresh, false forever for a closed workflow.
  • Ids aren't UUIDs. sid8 = id.slice(0,8) joins, SESSION_ID_RE, and isJunkName's hex-tail rule would label every workflow id defective. The row type becomes a discriminated union on sessionKind.
  • Status heuristics are transcript-shaped. classify() buckets on transcript staleness and gateArmed; a Smith row would always read idle. Its bucket must come from the status() query.
  • Rows are pass-through. sessions[] is served unvalidated; a second producer needs the allowlist treatment publicBlock already models for blocks.

The deeper reason "never keystrokes" is a law, not a taste: the intake branch already had to rule that a requester's text may never be interpolated into a kickoff string, because send-keys into a bypassPermissions TUI turns text into shell access. There is no safe way to deliver untrusted text through a keyboard — Temporal signals and inbox files are the only doors that treat text as data.

Argued against before being recommended

10

The honest case against — and where it actually lands

DETERMINISM"An LLM inside a replayable workflow is a hazard." True at the workflow layer, fully resolved at the activity boundary — replay reads recorded results, never re-runs the model. Smith's whole brain has run this way for months, single-attempt, heartbeating. This objection does not survive contact with the codebase.
ITERATION TAXThe real cost the determinism worry was pointing at: every workflow-code change in a long-running workflow needs workflow.patched() forever-stable ids (Smith's reply carries seven). Mitigation is structural, slide 06: the loop stays dumb, behavior lives in the skill. If we catch ourselves patching the loop weekly, the design has failed and we should say so.
WHAT DURABILITY DOESN'T BUYTemporal makes the skeleton immortal, not the judgment. Each wake is a fresh judge with no memory beyond the files — the accrued suspicion a long-lived session builds ("it claimed done twice already") survives only if written to events.jsonl. The operator skill already mandates exactly that discipline (STATE.md-only reads, append-as-you-go), so the loss is small — but it is a loss, and it's why the Worker and Architect stay put.
A COARSER SPOFToday operators fail one at a time; tomorrow one worker daemon on the mini judges every task — if co.propflow.smith-worker is down, all judging pauses. Softer than it sounds: signals queue durably (nothing is lost, unlike dead-session mail today), launchd KeepAlive + the 20-min liveness workflow already watch that daemon. Still a real regression in failure granularity.
THE PANE IS STILL THE PANESteering a tmux Worker remains msg — typed text, submit races, viewer-pane traps. Temporal fixes the caller's mortality, not the transport. Anyone selling this as "no more tmux fragility" is overclaiming; it is "the fragile thing is now retried by something that cannot die."

Where I disagree with the instinct that prompted this deck: nowhere on direction, once on emphasis — the interesting risk was never determinism (solved, boring), it is the iteration-speed tax and the discipline required to keep the loop dumb. That is what the pilot exists to measure.

Each stage ships alone, pays alone, and can be the last

11

Four stages. Any of them can be the stopping point without waste.

0→1

Smith rows, read-only

Land the relay source PR (#11 — stage 0, already written). Add getSmithSessions() + sessionKind union + timeline panel swap. Buys: Smith visible next to the fleet.

days · abandonable: pure read projection

2

Talk to Smith

Composer → signal-with-start; amend the read-only test by name; move inbound logging into the workflow. Buys: two-way, through the existing door.

days · abandonable: one route + one test revert

3

Operator pilot, one task

TaskOperationWorkflow + judge activity; opt-in via executor:"temporal" in task.json, which the Supervisor skips — clean partition, both systems run side by side. Buys: proof, on a real task, that the dumb-loop discipline holds.

~a week · abandonable: drop the flag, Supervisor re-mints a session operator; task.json contract unchanged

4

Cutover & retire

New tasks default to Temporal operators; retire operator respawn/liveness/adoption/mail-rescue. Supervisor keeps Workers, intake, Digester, compaction. Buys: slide 07's left column, deleted.

only after ≥2 clean pilot tasks · reversible per-task by flag

Deliberately not on this path: the Architect (revisit only if the pilot shows the judge losing nothing across wakes) and the Worker (never — slide 02). Stage 3's exit question is written in advance: did any behavior change require touching workflow code? Zero is a pass; more than one is a design failure and we stop at stage 2 with nothing wasted.

Answer here — the machinery reads this page

12

Your call

Recommendation: proceed staged — Smith into the feed now (stages 0–2), the Operator pilot behind it (stage 3), cutover only on evidence. Three things were deliberately left open for you, listed under the options.

Left open, on purpose: (1) where Smith's row lives day-to-day — mixed into the default /agents view or behind a source filter; taste call. (2) Whether stage-2 "talk to Smith" waits for the intake PR's write-surface decision — both add write paths to a read-only page and should probably be one conversation. (3) The judge's model tier — the operator skill implies full-price judgment ~30×/task; Smith's tiering precedent (Sonnet standing, Fable for finals) would cut that, but that's a cost call with your name on it. Companion: the as-built deck (/a/operator-architecture.html) now carries two PROPOSED slides pointing here.

All slides

PropFlow Docs