Chats, Channels & Identity¶
Overview¶
This document is authoritative for how a chat relates to the channels that reach it, and how identities and credentials attach to the actors involved. It resolves a terminology collision that previously existed between the code (channels table = chat container) and the design docs (channel_identity = transport), and it defines the model for multi-channel participation, external identity resolution, and per-principal credentials.
It builds on, and refines, 06 Events, Channels & Messaging (which it supersedes on terminology and the channel↔chat relationship), 04 Data Model (entity definitions), and 07 Security & Governance (credential vault, policy). The decisions here are recorded as ADR-023 through ADR-027 in the Decision Log.
Status note. This doc states the decided target model. The terminology rename is now implemented in code (
channels → chats; see §9) — the schema matches §1. The behavioural design in §3–§8 (binding modes, identity flow, principal credentials, delivery) is not yet built; it is the design we build to.
1. Terminology (World B)¶
The word "channel" was used for two different things. It is now reserved for exactly one.
| Term | Meaning |
|---|---|
| Channel | The transport a participant reaches Thinklio through: web, mobile, Telegram, email, SMS, API. Each channel has an identity namespace and a capability profile. |
| Chat | The container for interaction: members (users + agents), messages, and threads. (Currently the channels table — to be renamed chats; see §8.) |
| Thread | A reply-thread within a chat (existing threadId). |
| Gateway / channel adapter | The code that ingests from and delivers to one channel, normalising to/from chats + messages. |
Consequence: the design-doc tables channel_identity and user_channel are now correctly named (channel = transport). The container is what gets renamed. See ADR-023.
2. Chat model¶
A chat is the canonical, channel-agnostic record of an interaction. It is the single source of truth; channels are merely windows onto it (§3).
- A chat has members, each of which is a principal — a user or an agent (see §6). This is the existing
chat_members.memberType: user | agent. - A direct chat between a user and an agent is the unit that "follows you everywhere": there is one such chat per
{user, agent}pair, regardless of how many channels touch it. - Group chats (team, company, custom set) have many members and behave identically — only the member count and which channel window is bound to them differ (§5.4).
Because the chat is canonical and Convex queries are reactive, synchronisation across channels is structural, not code we write: once a message lands in a chat, every subscribed window updates live.
2.1 Types, roles & posting¶
A chat carries a type, and each member a role. Together they replicate real-world team structures with no extra primitives:
type:direct | private_group | public_group | team | organisation- member
role:owner | admin | member | observer
Moderation is by role. For broadcast chats (announcements) where everyone reads but only admins post, the chat carries a posting policy keyed off role — not per-member read/write flags:
postPolicy:everyone | admins(defaulteveryone)
postPolicy: admins means members and observers read; owners and admins post. This is cleaner than demoting every member to observer, and it reuses the existing role enum.
2.2 Worked validation — a clinic's existing chats¶
A real clinic's Telegram structure maps directly onto the model, with no new types:
| Existing chat | Members | Thinklio type |
Note |
|---|---|---|---|
| FALL Chat | everybody | organisation |
all post |
| FALL Clinical | clinical staff | team |
auto-membership if clinicians is a team |
| FALL Announcements | everybody; admins post | organisation, postPolicy: admins |
read-all, write-admins |
| Front Desk (per clinician) | clinician + admin staff | private_group |
many such rows |
| 1:1s | two people | direct |
DMs |
Adding an agent to any of these is a single chat_members row with memberType: "agent"; its triggerMode (mention | proactive | silent) governs how it participates.
3. Channels as windows — three binding modes¶
A channel is a window onto a chat, but channels bind in different ways depending on the channel's capability and the identity behind it. There are three binding modes (ADR-024):
| Mode | What it is | Sync behaviour | Channels |
|---|---|---|---|
| Mirror | Live, bidirectional, authenticated Thinklio user. The channel renders the chat and writes to it in real time. | Sees everything; full Convex reactivity. | web, mobile, Telegram linked to a user |
| Relay | Asynchronous, bidirectional, but the channel shows only its own slice, not the whole chat. | Messages in, replies out; no full mirror. | email, SMS |
| Injection | One-way / fire-and-forget inbound from a non-human source; reply path may differ or be absent. | Writes in; does not render. | webhooks, API events, scheduled triggers |
Cross-cutting this is who is behind the channel: a Thinklio user (a full member, mirror-eligible), an external contact (sees only their own slice, never the Thinklio UI), or a system source (no identity).
This is why "Telegram is just a window" and "a webhook posts into a chat" are not in tension: they are Mirror vs Injection onto the same canonical chat.
3.1 The mirror guarantee¶
For Mirror-mode channels, the rule is: the same chat, seen everywhere. A user typing in the web app and on a linked Telegram account is in one chat; both surfaces show the full message history and update live. The mobile app is a mirror of the web app and introduces nothing new.
A corollary: because a user's DM-with-agent is a single chat across all its channels, agent memory is simply per-chat — there is no separate "cross-channel memory" layer to build.
4. Identity & bootstrapping¶
To route an inbound message from a channel, Thinklio must resolve the external address to a principal, then to a chat.
4.1 The channel_identity table¶
Identity mapping lives in a dedicated table, not as inline fields on user_profiles (a principal may hold several identities on one channel, and inbound routing needs an indexed lookup):
channel_identity
principalType "user" | "agent"
principalId id
channel "telegram" | "email" | "sms" | "whatsapp" | ...
externalId string // Telegram numeric id, normalised email, ...
relation "owns" | "reachable_at"
displayName string? // for display only — NEVER used for matching
status "pending" | "verified" | "revoked"
verifiedAt number?
index by_channel_external (channel, externalId) // the inbound lookup
index by_principal (principalType, principalId)
The relation field captures direction:
- A user owns an external id → an inbound message from it is authored by that user.
- An agent is reachable_at an address → an inbound message to it is answered by that agent.
The email router uses the table twice per message: resolve the From: (a user who owns it, else an unknown → contact) and the To: (the agent reachable at it).
A lightweight denormalised handle (e.g. telegramHandle) may live on user_profiles for display, but channel_identity is the authority.
4.2 Establishing a link — verified binding, not an allowlist¶
External identities are bound by proof, never by matching a self-claimed name/handle (handles are spoofable — matching on them would be an auth hole). The mechanism is a one-time deep-link token (ADR-025), worked through for Telegram:
- In the web app (already authenticated → we know
userId+accountId), the user clicks Connect Telegram. Thinklio mints a short-lived, single-use token bound to(userId, accountId)and renders a deep link:https://t.me/<bot>?start=<token>. - The user taps it → Telegram opens the bot and sends
/start <token>. The bot webhook receives the token plus the Telegram-supplied, unforgeable user id. - Thinklio resolves the token →
(userId, accountId), writeschannel_identitywith the realexternalIdandstatus: verified. The binding is now proven: only someone holding the token in an authenticated session and controlling that Telegram account can complete it.
Fallback (user finds the bot first): the bot replies with a code to paste into the web app, completing the same binding in reverse.
4.3 Identity ≠ authorisation¶
Resolving who a sender is does not decide what they may do. Authorisation is the existing, channel-agnostic governance layer: - An admin may enable/disable a channel per account or team (per-channel enablement). - A user's role and policies govern what an agent will do for them — the same checks as web, applied after identity resolution.
4.4 Routing an inbound message to a chat¶
Once (channel, externalId) resolves to a principal (and account context), routing decides the chat. v1 rule: one bot ⇒ one agent. The bot represents a specific agent; an inbound DM finds-or-creates the direct chat with members {user, agent} and appends the message. The reply is delivered back over the same channel (Mirror). Because it is the same chat row as the web DM, the user sees it in both places.
4.5 Unknown senders¶
An external id with no channel_identity, on a closed-account bot: reply "not connected" and stop — this is the real "allowlist", enforced as must be a verified, authorised principal, not a name match. The alternative (an external person reaching a public agent, becoming a contact with a fresh chat) is the external-party case — see §10.
5. The principal model¶
Identity and credentials are not user-only. They attach to a principal, of which there are three (ADR-025):
- User — a person.
- Agent — a first-class actor with its own name, addresses, and accounts.
- Account — a credential holder (shared org secrets); not itself a chat participant.
(The sets are not perfectly uniform: accounts hold credentials but do not chat. That asymmetry is intentional.)
5.1 Inbound identity — channel_identity, keyed by principal¶
As in §4.1: a user principal owns a Telegram id (inbound author); an agent principal is reachable_at an email address (inbound recipient → routes to that agent).
5.2 Outbound credentials — credential, scoped by principal¶
credential
scope "user" | "account" | "agent" | "platform"
ownerId id // user | account | agent-assignment | (null for platform)
integration "cliniko" | "notion" | "xero" | "google" | "email" | ...
secretRef → encrypted vault // never returned to the client
| Credential | scope | who acts |
|---|---|---|
| User's Cliniko key | user |
agent borrows the user's identity |
| Account Notion / Xero token | account |
any authorised user, shared |
| Assistant's own Google account | agent |
agent acts as itself |
| Research service keys | platform |
Thinklio ops; user never sees them |
Two principles:
- The integration declares its scope. A tool/MCP server's /meta returns credentialConfig.scope = user | account | platform (extended here with agent). Thinklio reads the declared scope and resolves the credential from the matching tier at call time — it never guesses. (See 09 External API & Tool Integration.)
- Borrow vs own. "The assistant has its own Google account" (agent-scoped credential) is distinct from "the assistant can read my calendar" (user-scoped credential the agent borrows). Making the principal explicit expresses both without special cases.
5.3 Where an agent's identity & credentials attach: definition vs assignment¶
- Agent definition — the catalog template and the
agentsinstance (system prompt, model, tools): shared config. - Agent assignment —
agent_assignments(scope user/team/account) and the per-useruser_agent_config: this is where per-context identity and credentials hang.
Therefore:
- Personal assistant = an agent assigned at user scope. Its name, email (assistant.andrew@…), and Google account attach to that user's assignment. Two users running the same assistant definition have different identities and credentials.
- Company/team agent = an agent assigned at account/team scope. Its support@… inbox and shared credentials attach there, once.
5.4 Group vs 1:1 — one model¶
- 1:1 personal assistant: chat type
direct, members{user, agent(user-assignment)}. A channel window binds directly to it. - Company/team chat: chat type
team/group, members{users…, agent(account/team-assignment)}. A Telegram window relays it into the user's bot DM (author-prefixed in; the user's replies post as that user out).
The agent is a member either way; only the bound chat and member count differ.
5.5 Security & sharing rules¶
- Encryption.
user_agent_config.credentialsand the account/agent vault must be encrypted at rest and never returned to client queries. A personal Cliniko key must never reach the browser. - Whose credential in a shared chat? If an agent in a group uses a user-scoped tool, the member whose credential applies is ambiguous. Cleanest rule: user-scoped-credential tools are live only in that user's own DM with the agent, or require explicit "acting as" resolution. This intersects Decision Log ADR-021 (tool-permission intersection in shared channels).
6. Worked examples → model¶
| Thing | Principal | Lives in | Note |
|---|---|---|---|
| User's Telegram | user (owns) |
channel_identity |
inbound author |
| Assistant's email address | agent (reachable_at) |
channel_identity |
inbound recipient → routes to agent |
| Assistant's name | agent | agents / assignment |
display identity |
| User's Cliniko key | user | credential (user) |
agent borrows |
| Account Notion token | account | credential (account) |
shared |
| Assistant's Google account | agent | credential (agent) |
agent acts as itself |
The model reduces to: chats have principal members; principals hold inbound identities (channel_identity) and outbound credentials (credential); agent principals attach those at the assignment scope that matches how the agent is shared.
7. The unified mental model¶
chat (canonical, channel-agnostic)
└── members: principals (user | agent)
└── each principal has:
├── channel_identity[] (inbound addresses — owns / reachable_at)
└── credential[] (outbound secrets — user/account/agent/platform)
channel (transport) ──[binding mode: mirror | relay | injection]──▶ chat
A channel never holds chat state; it is a window with a binding mode. A principal carries the identities and credentials. The chat is the truth.
8. Delivery & capability¶
An agent emits one logical reply; channels are unequal in what they can render. The rule is compose once, render per channel — the agent stays channel-unaware (ADR-026).
Companion — the cross-product Interaction Protocol. This section is Thinklio's implementation of a contract shared with our other products (Twikka, Couple Tools): the Interaction Protocol (
dev/docs/interaction-protocol.md, outside this repo). The protocol is authoritative for the product-neutral parts — the canonical envelope andkindtaxonomy, the four input lanes (structured / language / media / ambient), the affordance schema and lifecycle, the render→respond→handle round-trip, and versioning / graceful-degradation. Doc 16 owns the Thinklio specifics it instantiates: chats, principals, governance, and the binding modes (§3). Where the two appear to disagree on the shared contract, the protocol wins.
8.1 The semantic message¶
A message is channel-agnostic: markdown prose plus an optional array of typed affordance blocks. Every affordance carries a text fallback, so any renderer can degrade safely.
message = {
prose: markdown,
affordances?: TypedBlock[], // choices | action | form | file | citation | ...
lifecycle: "streaming" | "final",
}
// invariant: every affordance block carries a textFallback.
Prose is what the LLM streams; affordances are emitted by the agent through structured affordance APIs/tools, not typed into the prose. Define the envelope now; grow the affordance catalogue lazily as the native UI component library gains elements.
8.2 Render by binding mode¶
- Mirror (native web / Flutter / desktop): store the canonical message once; the client picks a UI element per block from its component library, falling back to
textFallback. No server-side rendering, no per-channel copies. - Relay / Injection (Telegram / email / SMS — future): the egress adapter renders the canonical message into native form at finalisation.
- API: serialise the canonical message (minus internal fields); the structured consumer gets the structure.
8.3 The channel adapter contract (deferred spec)¶
Every non-native channel implements this when built. Specifying it now means delivery is designed without building anything:
capabilityProfile— what the channel accepts outbound:{markdown, buttons, attachments, maxLength, streaming, editable, …}. This is our data; the remote channel does no processing — our gateway does all rendering.render(message, profile) → nativePayload— outbound; usestextFallbackwherever a capability is absent. Never drop meaning, only interactivity (buttons → "reply ½/3").normalizeInbound(event) → message | affordanceResponse— the reverse path (a button-tap or numbered reply mapped back to the affordance it answers).- delivery semantics — streaming? editable-after-send?
8.4 Posture for v1 — native only (ADR-027)¶
All conversational participation is Mirror on native clients (web / Flutter / desktop). Non-native I/O (API, inbound email, webhooks) is Relay / Injection only — it posts into chats or triggers agents but never reproduces a chat UI. Third-party conversational Mirror channels (Telegram-as-a-window) are not built; §8.3 is the hook to add them later with no change to the agent or chat model. The tradeoff is adoption — staff must use the apps rather than an already-installed Telegram — which is a product/GTM lever, not an architecture constraint.
8.5 Edits & delivery routing¶
- Post-delivery edits. The canonical message is the source of truth. Mirror reflects edits and deletions live (free via reactivity); non-mirror is best-effort (Telegram can
editMessageTextwithin a window; email is immutable). Nothing depends on a non-mirror edit landing. - Delivery routing. Default is reply-in-kind (answer on the channel the message arrived on). Presence-aware notification fan-out to other channels is deferred — the pieces exist (
presence,chat_members.notifications).
9. Current implementation status & migration¶
This model is design-ahead-of-code. Current convex/schema.ts state and the rename map:
| Old name (code) | New name | Status |
|---|---|---|
channels |
chats |
✅ done (refactor/channels-to-chats) |
channel_members |
chat_members |
✅ done |
messages.channelId |
messages.chatId |
✅ done (all 8 channelId fields renamed) |
channels.ts (functions) |
chats.ts |
✅ done |
web components/channels/, route [channelId] |
components/chats/, [chatId] |
✅ done |
| — | channel_identity |
not built (design) |
user_agent_config.credentials |
credential (principal-scoped) |
partial; to generalise |
| account secrets vault | credential (account/agent scope) |
per 07 |
The channels → chats rename is complete (branch refactor/channels-to-chats): all chat-container tables, fields, indexes, the Convex function module, web components, and the route segment were renamed; transport-meaning uses of "channel" (the settings "Connected Channels" surface, governance transport arrays, the items.source: "channel" enum) were deliberately preserved. Test data in the affected tables was cleared on both dev and prod first (Convex cannot rename tables/fields in place). Validated: convex codegen pushed to dev cleanly, and web tsc shows 106 errors before and after the rename (zero introduced — the 106 are pre-existing @convex-dev/agent v0.6.0 migration debt and unused-locals).
What exists today: internal chats (web only), chat_members with user/agent members, agent execution with per-chat memory, and the account/user credential split. What is not built: any non-web channel (no Telegram/email/SMS ingress — the only HTTP route is /clerk-webhook), channel_identity, the verified-link flow, the generalised credential table, and agent-principal identities/credentials.
10. Open & deferred items¶
- Multi-account routing context. A user in several accounts reaching Thinklio over one channel — which account context applies, and how to switch.
- One bot ⇒ many agents. Agent selection within a single plain-text channel.
- Group participation from a channel. The relay UX for showing/switching which chat a Telegram window is bound to.
- External-party chats. Unknown senders becoming contacts on public agents (support inbox, web chat widget).
Cross-references¶
- 04 Data Model — entity definitions;
chats,chat_members,agent_assignments,user_agent_config, and the newchannel_identity/credentialtables land here once stabilised. - 06 Events, Channels & Messaging — superseded by this doc on terminology and the channel↔chat relationship; still authoritative for the event model and the Workflow harness.
- 07 Security & Governance — credential vault, encryption, policy evaluation, ADR-021 tool-permission intersection.
- 09 External API & Tool Integration — the
/metacredential-scope declaration. - 15 Tenancy & Deployment Topology — account context and isolation.
- Interaction Protocol (
dev/docs/interaction-protocol.md, cross-product / outside this repo) — the product-neutral contract this doc instantiates: canonical envelope,kindtaxonomy, input lanes, affordance schema & lifecycle, render→respond→handle round-trip, versioning. Shared with Twikka and Couple Tools; §8 here is Thinklio's render+delivery instance. - Decision Log — ADR-023 (terminology), ADR-024 (chat-canonical + binding modes), ADR-025 (principal identity & credentials), ADR-026 (universal semantic message; compose once, render per channel), ADR-027 (native-only Mirror for v1).