threadkit
Message threads where permission is participation — not a role column. An AI obeys the same rules as a person.
Why it exists
A chat that special-cases the assistant becomes a second permissions system. The assistant sees things the person cannot, or the person sees the assistant's notes. Both are bugs.
How it works
A thread is a set of participants. Unread is per person. An AI can be a participant, and it sees exactly what its participation allows, which is the same rule as everyone else. No markup, no transport: the app keeps those.
Where it fits
OrangeCat, Heidi and Substrata already share a conversation this way. It is how a person and an agent inhabit one room.
API and examples
Documentation from source commit 2f5acf54e412 · 2026-11-07. Source documentation can include work beyond the registry release.
- The idea
- Why this matters more than it sounds
- What you get
- AI as a participant
- Bring your own storage
- API
- Licence
Read the package documentation
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.
pnpm add @bitbaum/threadkit
The idea
Most 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.
Why this matters more than it sounds
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 '@bitbaum/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.
What you get
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 badge
Optimistic 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.
AI as a participant
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 '@bitbaum/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.
Bring your own storage
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.
API
| `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 |
Licence
MIT
Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use threadkit.
Changelog and releases
Read the repository changelog
Renamed — threadkit is now @bitbaum/threadkit
Same code, same version, same repository; the package now lives under the @bitbaum npm organization like the rest of the fleet's packages. Install @bitbaum/threadkit and import from it (threadkit stays installable and is deprecated once no adopter uses it). No API changed.
This retrospective record was added on 2026-10-27 from the immutable sources linked below. Git commit dates are source dates, not inferred npm publication dates. A GitHub release does not itself establish npm publication. Application adoption and deployment evidence are tracked separately.
0.1.1
- Tagged source
v0.1.1 - Source commit, dated 2026-08-20.
No authored release notes were found for this tag when this record was made. The links identify the verified source; missing feature history is not invented.
No release notes have been retrieved. This does not mean the package has never changed.
Recent source changes
Repository activity can include unreleased work; it is not a list of deployed changes.
- chore(deps-dev): bump the minor-and-patch group with 2 updates (#53)
- ci(publish): the current Publish workflow, which can make a package's first npm release (#52)
- feat: publish as @bitbaum/threadkit (same code, same version) (#51)
- docs(agents): the org rules block, generated from bitbaum/fleet (#50)
- chore(deps-dev): bump the minor-and-patch group with 2 updates (#49)
- chore(deps): clear dev-toolchain audit findings inside existing ranges (#48)
- docs(agents): the org rules block, generated from bitbaum/fleet (#47)
- fix(release): apply verified build-output staging contract (#46)
Where it is declared
Linked products declare this package in their manifests. A declaration alone does not verify every feature, version or deployed integration.
Sources and verification
Install commands and declared consumers come from Fleet’s generated registry. Documentation and release notes come from the package repository. The catalogue does not certify production journey coverage.
Documentation from source commit 2f5acf54e412 · 2026-11-07. Source documentation can include work beyond the registry release.



