message.received for "is jacob free tuesday?" to https://picortex.globalbr.ai/api/linq/inbound. HMAC verified. Row inserted in messages on A's SQLite.claude -c -p per turn)The platonic ideal realized with two small Linux boxes and one HTTPS call to noos. No Docker. No Kubernetes. No tmux. No long-lived REPLs. A small bot server handles the channel and routing; a separate little machine is where Claude actually runs, one SSH-exec per turn.
Bot server on box A. Per-chat Linux users + home dirs on box B (literally "a little machine you can open a Claude on"). Each turn: A opens an SSH session as chat-X on B and runs claude --session-id X -p "<prompt>". stdout is the reply. noos graph is reused on its existing box (C). Bot server's crash doesn't lose any Claude sessions — they're on B.
claude -p-per-turn pattern was pioneered by Piyush Jha in pre-container Cortex (SHAs 238052c4 → d2d6a534, Jan 20-23 2026). For what he actually shipped — web-chat only, per-user workspaces, no consent loop, no knowledge graph — see Mockup 03 · Piyush's literal architecture. This mockup keeps the pattern and adds:
| Borrowed from Piyush | New here |
|---|---|
| Split: bot ≠ workspace host | Per-chat (not per-user) Linux users |
| SSH exec per turn | Attention gate (mentions-only, discriminator) |
claude -c -p | Consent broker + out-of-band DM approvals (PRD 002 P4) |
| Narrow sudoers (useradd/chmod only) | noos knowledge graph (box C) |
| Per-user apiKeyHelper | Linq / OpenChat channel (not web chat) |
UX is the same as the platonic ideal. This page focuses on the mechanical architecture. For the user-facing scenes (attention, consent loop, manifest dialog, reactions), see Mockup 00.
| Component | Box | Process / store | Cost of failure |
|---|---|---|---|
| Channel adapter + bot logic | A | Fastify service, systemd | Inbound queue stalls; no data loss (Linq retries). |
| SQLite canonical log | A (local disk) | /var/lib/picortex/picortex.sqlite | Fatal. Daily litestream → S3. |
| Consent broker (pause state) | A | In-memory + SQLite rehydration row | Recovered from SQLite on restart; pending group waits survive. |
| Per-chat Linux user + home dir | B | /srv/picortex/chats/<chat> | Only that chat's context/memory lost if B is wiped. |
| Claude session memory | B | ~chat-X/.claude/ | Per-chat. Each chat resurrects with fresh memory; transcript rebuild possible from A's log. |
| noos graph | C | Neo4j (existing Lightsail deploy) | Bot degrades gracefully: "I can't reach my knowledge right now." |
message.received for "is jacob free tuesday?" to https://picortex.globalbr.ai/api/linq/inbound. HMAC verified. Row inserted in messages on A's SQLite.calendar not in scope.DisclosureEvent (approval_mode: pending, ttl_expires_at: +10min). Sends group ack via Linq sendMessage: "Let me check with him — one sec." Typing indicator on.y. Broker matches to the pending DisclosureEvent, marks approval_mode: approve-exact, expands turn scope to include calendar:2026-04-28T19:00/21:00.ssh -i /etc/picortex/keys/b_admin.pem picortex@B
sudo -u chat-a1b2 -H claude --session-id chat-a1b2 -p "<system+context+prompt>"~/.claude/sessions/chat-a1b2/ for prior turns on this chat. Calls optional noos MCP tool to check calendar. stdout = proposed reply.final_reply + grant_ttl: single-use.// boxA/src/executor.ts — per-turn dispatcher (Option 2 · No-Docker)
import { SSHClient } from './ssh.js'
import { scopedCtx } from './manifest.js'
export async function executeTurn(chat: Chat, message: Message, scope: Scope) {
const turnId = ulid()
const ctxSystem = buildSystemPrompt(chat, scope) // manifest-filtered
const prompt = formatTurnInput(message)
// One SSH exec per turn. No tmux, no sentinels.
const { stdout, code } = await ssh.execAsUser(
chat.unix_user,
['claude', '--session-id', chat.id, '-p', '--dangerously-skip-permissions', '--system', ctxSystem, prompt],
{ env: { PICORTEX_TURN_ID: turnId }, timeout_ms: 120_000 },
)
if (code !== 0) return replyFailure(stdout)
return recordAndSend(chat, stdout, { turnId })
}
// On pause: write DisclosureEvent row, send DM, return to event loop.
// On Jacob's reply: match by (jacob_dm_chat_id, in-flight-row), re-invoke executeTurn with expanded scope.
// On timeout: clear row, send group "I need to check on that offline", log approval_mode="timeout".
cat ~/.ssh/id_rsa runs on B; the DM-approval state and Linq webhook secret live on A. Two independent blast radii.-p + stdout is the entire reply-capture contract.authorized_keys hygiene, monitored sudoers drop-in. Manageable but not zero.--session-id contract + ~/.claude/ layout are upstream-owned. Drift = work.| Failure | User-visible | Recovery |
|---|---|---|
| A restart mid-turn | Brief typing indicator gap; bot posts "back online, here's that answer" if turn was in consent-loop pause | Rehydrate paused DisclosureEvents from SQLite on boot |
| B down | Bot: "I'm briefly unable to think — one moment" in DM; groups get nothing until B returns | Retry turn once B is up; Linq retries inbound for up to 24 h |
| C (noos) down | Bot: "I can't reach my knowledge graph right now — try again?" | Attention gate still works; non-graph questions still answered |
| SSH key rotation mid-flight | Single turn fails loudly; reply is "I had a glitch, try again" | Key reloaded from Vault/env on next turn |
| Claude CLI OOMs on B | Turn errors out; reply "Jacob's bot had a glitch" | cgroup memory limit per chat user prevents cross-chat impact |
Codex's session review flagged S3 (single shared tmux) and the tmux sentinel protocol as the weakest parts of the original picortex plan. The claude -c -p-per-turn-over-SSH pattern sidesteps both — Piyush already ran the spike in Jan 2026 and it worked. The boxes are cheap, the isolation story matches the threat model, and the consent-loop broker pattern composes naturally on top.
bubblewrap or Landlock — composes naturally, no re-architecture.Compare with the platonic ideal, the stateless Option 4, or what Piyush actually shipped.