In Qorven, a Qor is a person. You don’t have N separate conversations with it — you have one. That conversation is reachable from the web UI, the TUI, Telegram, WhatsApp, Slack DMs, Discord DMs. Messages from every channel land in the same thread; each message keeps a channel tag so the right surface renders the right slice.

Why this matters

Most multi-agent platforms let you create a new chat every time you message an agent. By week two you have 47 “New Chat (3)” threads and no idea which one you told it your laptop password was broken. Qorven collapses that to one canonical chat per Qor per user:
  • Web UI and TUI show the full timeline, badge-tagged by channel
  • Telegram shows only the messages tagged telegram
  • WhatsApp shows only the messages tagged whatsapp
  • The Qor’s memory is shared across all of them

The mental model

What counts as “one chat”

SurfaceMerges into the canonical chat?
Web UI
TUI
Telegram DM
WhatsApp DM
Slack DM
Discord DM
webchat (embed)
Email❌ — different medium (subject lines, threading, attachments)
SMS❌ — different medium
Group chats (Slack channels, Telegram groups, Discord servers)❌ — group-scoped, multi-party
Voice calls❌ — different medium, transcripts flow into memory
Cron runs❌ — background, no user conversation

How it’s stored

One canonical sessions row per (tenant, agent) in Postgres. Its messages JSONB column holds the merged timeline, chronologically sorted. Each message carries:
{
  "role": "user",
  "content": "What's next on my calendar?",
  "timestamp": 1776787200000,
  "channel": "telegram",
  "sender_name": "Priya"
}
On the wire:
  • Web UI fetches the whole session, renders every message with a channel badge for non-web messages
  • Telegram’s “get history” command filters channel = "telegram" before sending back
  • Per-surface filters use an index on (session_id, channel, created_at)
Full memory system →

What happens on each inbound

1

Channel webhook hits /v1/webhooks/{channel}

Telegram / WhatsApp / Slack send the message to Qorven’s HTTP endpoint for that channel.
2

Dispatcher resolves the Qor

gateway.go finds which Qor owns this channel binding. If the channel is chat-family (web, tui, telegram, whatsapp, slack_dm, discord_dm, webchat), it looks up the canonical session. Groups / email get their own row.
3

Message appended with channel tag

sessions.AppendMessage(sessionID, {role, content, channel: "telegram", sender_name}). The channel field is the key piece — it survives compaction, survives re-render, survives everything.
4

rtHub broadcasts to connected surfaces

Web UI and TUI subscribers get a new_message event. They decide whether to render (web shows everything, other channels filter).
5

Agent loop runs

Prime (or this Qor) gets called, uses the full merged context to answer, sends the reply. The reply includes channel = "<whichever channel you sent from>" so it goes back to the same surface.

Long conversations don’t degrade

One canonical chat across all channels sounds great until you realise: by month six, the messages column could be 50 MB and the LLM context would blow up every request.
Qorven handles this with three layers:
1

Sliding window for LLM calls

Only the last N tokens (N depends on the model) of messages get sent to the LLM each turn. The rest stays in the DB.
2

Automatic compaction

When the rolling window fills, the auto-compact hook summarises older turns into a short system note and evicts the raw messages from the window. The raw rows stay in the DB for audit. Compaction →
3

Memory emission

The compaction result is also written to the memories table as a conversation_digest fact, tagged by date range + channel. Future turns can retrieve it via the memory_search tool when relevant.
Net effect: the canonical chat stays small in the active context, unbounded in total history, and searchable forever via memory.

Migration path

If you’re upgrading from a pre-”one-chat” Qorven install that has many sessions per Qor, run:
sudo qorven collapse-sessions --dry-run   # preview
sudo qorven collapse-sessions              # apply
This merges extra chat-family sessions into the canonical one, dedupes on (role, timestamp, content), preserves each message’s channel tag, and emits one memory digest per archived session so nothing’s lost. Command reference →

What’s different from everyone else

PlatformTheir modelOurs
ChatGPTOne new conversation per topicOne continuous thread, forever
LangChainNo canonical state — you wire itBuilt-in
AutoGenN agents × M conversationsAgents are rows; conversations are canonical
Letta / MemGPTOne thread per agentSame
Slack botsPer-channel, no cross-surface memoryOne brain, many surfaces

Edge cases worth knowing

Strict chronological interleave. Both messages land in the canonical row with their timestamps; the Qor sees them in real time order. Race conditions are handled by Postgres’s SELECT … FOR UPDATE.
Multi-user Qors have one canonical chat per (user, agent) — each user sees their own thread. Group chats are explicitly different. Tenant isolation →
Set QORVEN_LEGACY_SESSIONS=1 in your config. Backwards-compat mode; not recommended. Future minor versions may remove the flag.
We don’t support forks. The model is “one thread, one memory.” If you want to restart with fresh context: ask the Qor to start a new topic; compaction handles it. If you want a genuinely different Qor, create one.

Where next

Channels

Every supported surface, setup per-channel.

Unified chat (UI)

How the web UI renders per-channel badges.

Memory & compaction

How long conversations stay performant.

collapse-sessions CLI

Migrating an older multi-session install.