Mockup 02 · Option 4Noos-style (stateless, per-turn, no workspace)

The platonic ideal realized with no workspace filesystem. Each turn is a fresh claude --print that receives system prompt + recent transcript + manifest-scoped context. Closest to how noos's existing Slack bot already operates. Fastest path to ship.

TL;DR architectural difference

One box for everything bot-related. No per-chat Linux users, no home dirs, no tmux, no SSH. Turns are stateless; continuity lives in SQLite + a system prompt rebuilt per turn. noos graph is still a drop-in over HTTPS.

UX is the same as the platonic ideal. For the user-facing scenes, see Mockup 00.

1The one box (plus noos)

Group chat iMessage / OpenChat Jacob's DM out-of-band approval MACHINE A · Bot (Fly.io machine, ~1 GB RAM) Fastify process · systemd/fly-proxy · auto-scales to zero when idle Linq / OpenChat channel adapter HMAC verify · dedup · normalize Attention gate mentions / discriminator Turn builder rebuild transcript + scoped context per turn Consent broker (paused turns) SQLite row per pending · timer · resume Ephemeral subprocess per turn claude --print --system "<sys>" "<transcript + input>" · dies after stdout SQLite canonical log messages · manifests · DisclosureEvent · paused-turn rehydration Outbound · Linq/OpenChat sendMessage + Disclosure audit each turn closes with a row; paused turns write the "let me check" ack noos graph (existing Lightsail) HTTPS · x-api-key · scoped read per chat GET /api/nodes?tags=... · GET /api/feedback · etc. HMAC webhook / sendMessage out-of-band DM HTTPS · noos API
machine service ephemeral subprocess out-of-band path in-channel flow

2What lives where

ComponentWherePersistence
Channel adapter + everything bot-relatedOne Fastify process on Fly MachineRestart-survivable via SQLite
SQLite canonical logFly volume (1-5 GB, cheap)Only durable state. Backed up nightly.
Claude session stateNowhere. Rebuilt every turn from SQLite messages.N/A — intentionally stateless
Per-chat workspace FSDoes not existN/A
Consent-broker paused turnsSQLite row: paused_turns(id, chat_id, proposed_reply, expires_at, …)Survives restart
noos graphExisting Lightsail deploy, HTTPS

3Concrete consent-loop turn (wire view)

  1. Inbound webhook. Linq POSTs to /api/linq/inbound. HMAC verified. Row inserted in messages.
  2. Attention gate passes (@mention). Turn builder reads chat_config + last N messages + manifest. Calendar not in scope.
  3. Consent broker writes paused-turn row with the planned scope expansion and proposed reply draft. Bot sends group ack via Linq: "Let me check with him — one sec." Typing indicator on.
  4. Out-of-band DM to Jacob with context + proposed response + shortcuts. Paused-turn row references this DM's message ID.
  5. Jacob replies y. Inbound handler matches the DM reply to the paused turn. Row updated: approval_mode=approve-exact.
  6. Turn runs. Turn builder rebuilds the transcript for the group + adds the now-approved calendar context. Spawns subprocess:
    claude --print --system "<sys with calendar ok>" "<transcript + ask>"
  7. stdout → outbound. Subprocess exits, bot sends final reply to group via Linq. Paused-turn row closed. DisclosureEvent written.

4Code sketch (the turn builder)

// src/turn.ts — stateless per-turn execution (Option 4)
import { spawn } from 'child_process'

export async function executeTurn(chat: Chat, msg: Message, scope: Scope) {
  const transcript = await loadTranscript(chat.id, { last: 12 })   // from SQLite
  const graphCtx   = await noos.fetchContext(chat.id, scope)        // HTTPS
  const systemPrompt = composeSystemPrompt(chat, scope, graphCtx)
  const turnInput   = formatTranscript(transcript) + '\n\n' + msg.text

  const claude = spawn('claude', ['--print', '--system', systemPrompt, turnInput], {
    env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },
    timeout: 90_000,
  })

  const out = await collectStdout(claude)
  return recordAndSend(chat, out)
}

Consent broker — pause state as a single SQLite row

CREATE TABLE paused_turns (
  id             TEXT PRIMARY KEY,
  chat_id        TEXT NOT NULL,
  source_msg_id  TEXT NOT NULL,
  dm_msg_id      TEXT NOT NULL,       -- Jacob's DM we're awaiting approval from
  proposed_reply TEXT NOT NULL,
  scope_expand   JSON NOT NULL,       -- {calendar: "2026-04-28T19:00/21:00"}
  approval_mode  TEXT,                -- null | approve-exact | approve-subset | deny | timeout
  expires_at     INTEGER NOT NULL,    -- unix ms
  created_at     INTEGER NOT NULL,
  resolved_at    INTEGER
);

5Strengths & costs, head-to-head

Strengths

  • Simplicity. One process. No SSH. No Linux users. No tmux. No workspace FS. Fits in a <500 LOC core.
  • Cheapest to run. ~$5/mo Fly machine that scales to zero when idle.
  • Trivially portable. Deploy anywhere Node runs. No OS-specific assumptions.
  • Fastest to ship. If the dev surface (terminal + file browser) is deferred, this is an order-of-magnitude less code than Option 2.
  • Inherently bounded. Each turn's context = transcript + explicit scope. No drift, no leftover state, no "what did Claude remember from three weeks ago?"
  • Well-trodden pattern. Matches noos's existing Slack bot; predictable failure modes.

Costs / risks

  • No workspace for code execution. If you ever want Claude to run things on your behalf, you bolt Option 2's workspace host onto this, or migrate.
  • Transcript cost. Every turn re-sends N messages to the model. Linear-ish token cost per conversation depth.
  • No session continuity other than the transcript. If a multi-turn thought needed scratch-pad state, you're rebuilding it each turn.
  • Consent-loop timing depends on one process. If the process restarts mid-consent-loop, you rely on SQLite rehydration — less clean than Option 2's A/B split.
  • Shared process memory. A bug that leaks one chat's state could cross chats (vs Option 2's OS-enforced separation). Mitigable with care; not free.

6Five failure modes & what happens

FailureUser-visibleRecovery
Process restart during a consent loopBrief delay; bot posts final answer when approvedPaused turns rehydrated from SQLite on boot; DM reply match resumes
Claude CLI crash mid-turn"I had a glitch, try again"Single-turn fail; no state to clean up
noos down"I can't reach my knowledge graph right now"Non-graph questions still answered; graph questions retry
Anthropic API rate-limit / outageQueue + "I'm saturated, one moment"Exponential backoff; drops to tier-2 model if configured
SQLite corruption (rare)Full outage until restoreNightly Fly-volume snapshot; restore in minutes

7Why this might be the answer

If PRD 002's v0.1 minimum slice ships without a dev surface (Q2 says "defer"), this is the simplest thing that hits every pillar. P4 consent-loop works cleanly with a single SQLite paused-turns table. Reactions and threading come for free from Claude CLI + Linq. Cost per month is lunch money. Option 2 becomes a possible upgrade path if/when code execution becomes the use case — but you don't need it to ship.

8Migration path Option 4 → Option 2 (if/when needed)

  1. Trigger. A real use case demands "bot runs a script / edits a file / uses rg" — not just "bot answers questions".
  2. Provision box B. Add the Claude box (workspace host); the channel adapter / SQLite / consent broker stay on box A unchanged.
  3. Swap the executor. spawn('claude', ['--print', …]) becomes an SSH exec to box B. Everything else is identical.
  4. Turn builder carries session-id. On B, use claude --session-id <chat> -p so Claude's own memory is in B's ~/.claude/ — full No-Docker (Option 2) shape.
  5. Migrate one chat at a time. New chats get workspaces immediately; existing chats inherit them lazily on next inbound. No big-bang cutover.

This migration is additive, not a rewrite. The Channel, SQLite, manifest, consent broker, and audit layer never move.

Compare with the platonic ideal (Mockup 00) or the 2-box No-Docker design.