Headless multi-participant message threads, with AI participants that obey the same rules as everyone else.
No UI, no database, no SDK. Pure functions over data you already loaded, so the rules that decide who can read what are things you can unit-test instead of things buried in a WHERE clause.
npm install threadkitMost messaging code encodes the number of participants into the schema. Two columns named participant_1 and participant_2. A patient_id on the thread. A read_at flag on the message row. Each of these is correct right up until the day a third party joins, and then every one of them needs a migration.
threadkit takes one position and derives everything from it:
Permission is participation. Not role, not ownership, not a column on the thread.
A thread is a set of participants and an ordered log of messages. Whether it is a two-party DM, a group with four clinicians, or a conversation where one participant is a language model is a property of the participant list — not of the schema.
Role-derived access reads fine and is wrong in a way tests usually miss:
if (session.user.role !== 'admin' && thread.patientId !== session.user.id) forbid()That is correct while the clinic has exactly one doctor. On the day it has two, every doctor can read every patient's thread — and nothing failed, so nobody finds out. The same shape hides in any app with one tenant, one workspace, or one org in production.
Participation-based access cannot express that bug, because there is no role to check:
import { canRead, visibleMessages } from 'threadkit'
if (!canRead(thread, actorId)) return forbidden()
const messages = visibleMessages(thread, actorId, allMessages)visibleMessages returns [] for a non-participant rather than throwing, so a caller that forgets to gate still leaks nothing. Failing closed is deliberate.
Joining does not grant history. A clinician added to a patient thread today sees what is said from today. Handing over the backlog is a decision someone makes on purpose:
{ actorId: 'dr-b', kind: 'human', joinedAt: now, visibleFrom: 'thread-start' }Leaving keeps the past and stops the future. A participant with leftAt retains what they already saw and receives nothing after it.
Unread is per person. A read high-water mark per participant, not a flag on the message. A flag on the message row means the first person to open a thread clears the badge for everybody — invisible with two participants, wrong with three.
unreadCount(thread, 'dr-b', messages) // just theirs
unreadThreadCount(entries, 'dr-b') // the nav badgeOptimistic sends reconcile instead of duplicating. mergeMessages matches on id, then on clientId, so the confirmed row replaces its placeholder rather than arriving beside it — the most common visible bug in a chat UI.
An AI is a participant, not an exception. It reads through the same visibility window as every human, so a model added to an existing thread sees what it was granted and nothing earlier.
import { runAiTurn } from 'threadkit'
const result = await runAiTurn(thread, messages, {
actorId: 'assistant-1',
systemPrompt: 'You support the patient between consultations.',
model: 'claude-opus-5',
complete: async ({ system, prompt, maxTokens, temperature }) => {
// your provider, your key, your model choice
},
})
if (result.status === 'responded') {
await store.append({ authorId: 'assistant-1', body: result.body, generatedBy: result.generatedBy })
} else {
log.info('assistant stayed quiet', result.reason)
}The package never imports an SDK and never reads an API key. You pass a complete function; how the text is produced is your business.
Defaults that keep it usable:
- It never answers itself. The loop guard runs before any policy, so a custom policy cannot talk it into an unbounded self-conversation.
- Two participants → it answers every turn. It is the conversation.
- Three or more → it waits to be addressed. A clinician and a patient talking to each other is not an invitation. Mentions match on whole words, so "I had to wait again" does not summon it.
- Silence is a normal outcome. A skipped turn returns a reason instead of throwing, and never spends a model call.
- Speakers are labelled by role, not by name. The transcript says
patient:, notDr. Schabus:— real names never have to reach the model, andParticipanthas no name field to leak.
Every reply carries generatedBy, so a record can say honestly which model wrote what.
There is no database adapter and no ORM. Load a thread and its messages however you already do, call the functions, write the result back. That is the whole integration surface — which is also why it works the same on Drizzle, Prisma, Supabase, or a JSON file.
canRead(thread, actorId) |
is this actor a participant at all |
canWrite(thread, actorId, now?) |
may they post right now |
visibleMessages(thread, actorId, messages) |
what they are allowed to see, in order |
canSeeMessage(participant, message) |
the single-message form |
visibilityWindow(participant) |
the [from, to) interval they may see |
readersOf(thread, message) |
who has seen it, excluding the author |
unreadMessages / unreadCount |
per participant |
unreadThreadCount(entries, actorId) |
threads with anything unread |
mergeMessages(existing, incoming) |
dedupe, reconcile optimistic, order |
compareMessages / isPending |
ordering and pending-state helpers |
runAiTurn(thread, messages, config) |
decide whether the AI speaks, and what it says |
afterEveryMessage / whenMentioned / defaultRespondPolicy |
response policies |
MIT