npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

threadkit

v0.1.1

Published

Headless multi-participant message threads. Permission is participation, unread is per-person, and an AI can be a participant subject to the same visibility rules as everyone else.

Readme

threadkit

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 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 '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 '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:, not Dr. Schabus: — real names never have to reach the model, and Participant has 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