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 messages table 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 Channel interface.
  • 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.timer runs hourly for idle reap + archive.
[[curator]]
I'm the Curator. I can help you navigate, organize, and curate this wiki. What would you like to do?