Architecture
One-paragraph summary
A chat (1:1 or group) arrives via Linq webhook. picortex looks up or provisions a Unix user + home dir + tmux session for that chat's stable ID. The inbound message is appended to the canonical SQLite log, then — after attention gating — injected into the chat's tmux session via send-keys, wrapped in turn sentinels. Claude Code's response is parsed out of the tmux pipe-pane log and sent back via Linq. A mobile-first web UI attaches to the same tmux session through an xterm.js WebSocket bridge and displays a browseable file tree of the chat's home.
Component diagram
┌────────────────────────────────────────────────────────────────────────┐
│ Linq (or linq-sim) │
└────────────┬───────────────────────────────────────┬───────────────────┘
│ HMAC-signed webhook │ /api/partner/v3/*
▼ ▲
┌───────────────────────────────────────────────────┴────┐
│ picortex backend (Fastify, TS, pino) │
│ │
│ Channel<Linq> ─▶ Router ─▶ AttentionGate ─▶ │
│ │ │ │
│ ▼ ▼ │
│ SQLite log TurnDispatcher
│ │ │ │
│ │ ▼ │
│ │ runuser -u chat-X │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ tmux picortex:<chat_id> │ │
│ │ └─ claude (Claude Code) │ │
│ │ └─ pipe-pane → session.log │ │
│ └─────────────────────────────────┘ │
│ ▲ ▲ │
│ │ │ │
│ ReplyCapture ────────┘ │ │
│ │ │ │
│ ▼ │ │
│ Channel<Linq>.send │ │
│ │ │
│ /ws/terminal/:chat_id ───────────────┘ node-pty │
│ │
└─────────────────────────────────────────────────────────┘
▲ ▲
│ HTTP + WS │ HTTP (files API)
│ │
┌───────────────────────────────────────────────────┐ ┌──────┐
│ Frontend (Vite + React, :7824) │ │ ... │
│ swipe-panels: messages │ files │ terminal │ └──────┘
└───────────────────────────────────────────────────┘
Invariants
- I1 Canonical log: the SQLite
messagestable is authoritative. The workspace FS is a cache. - I2 Per-chat user: no chat's bytes ever reach another chat's process, enforced by POSIX perms.
- I3 Sentinel protocol: every Claude turn is bracketed in tmux by unique start/end sentinels so reply capture is unambiguous.
- I4 Backend-as-root: the backend can enter any chat user via sudoers; chat users have no sudo.
- I5 Channel interface: business logic does not know about Linq, only about the
Channelinterface. - I6 HMAC on inbound: no unsigned webhook is processed. Full stop.
Data model (SQLite)
CREATE TABLE chats (
id TEXT PRIMARY KEY, -- from Linq
kind TEXT NOT NULL, -- '1on1' | 'group'
display_name TEXT,
owner_phone TEXT NOT NULL,
unix_user TEXT UNIQUE NOT NULL,
home_dir TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_message_at INTEGER
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
chat_id TEXT NOT NULL REFERENCES chats(id),
direction TEXT NOT NULL, -- 'inbound' | 'outbound'
author TEXT, -- phone or bot
text TEXT,
reply_to_message_id TEXT,
turn_id TEXT, -- present on outbound, ties to tmux turn
request_id TEXT NOT NULL,
raw_event JSON,
created_at INTEGER NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
chat_id TEXT,
request_id TEXT,
payload JSON,
created_at INTEGER NOT NULL
);
CREATE TABLE bridge_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_chat_id TEXT NOT NULL,
dst_chat_id TEXT NOT NULL,
path TEXT NOT NULL,
sha256 TEXT NOT NULL,
approver_phone TEXT NOT NULL,
challenge_message_id TEXT NOT NULL,
approval_message_id TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE chat_config (
chat_id TEXT PRIMARY KEY REFERENCES chats(id),
attention_mode TEXT NOT NULL DEFAULT 'mentions-only',
discriminator_model TEXT,
discriminator_threshold REAL,
updated_at INTEGER NOT NULL
);
Runtime
- picortex service: systemd unit
picortex.service, starts Fastify + Vite-built static frontend. - tmux per chat: spawned on demand under the chat user.
- cron:
picortex-lifecycle.timerruns hourly for idle reap + archive.