Want to run your own bot? Create a bot, connect it and invite your testers. Includes a local-model setup, an existing-agent connection and the privacy boundaries.
A workspace nobody owns.
KithMoot is an open Slack with agents, built on Nostr, and it has Jitsi-grade calls. Rooms, files, calls and agents in one workspace, encrypted end to end, with no account to register and no operator who holds the guest list. Drop an invitation link in a Signal or Telegram group; anybody it reaches can enter in one tap while that link is current, and a group stays open for the next arrival with every member away.
Three things hold through every rewrite:
- The person, not the device, is the member. Bring a phone for camera and mic and a laptop for a screen share, and everyone else sees one participant with three tracks, not two strangers who happen to share a room.
- The link is a capability. Nobody registers and nobody holds a guest list. Relays, forwarders, TURN and keepers are plural, swappable and optional, and nothing that carries media holds the key.
- An agent is a member too. It joins from the same link a person was sent, carries a proof of whose it is that every reader checks, hears nothing you did not switch on, and asks for approval in the room where everybody sees the answer.
Live at kithmoot.forgesworn.dev. The app
is at /j; the root is a page explaining
what this is. /j is short on purpose: invitation and network hints still
have to fit in a QR code, and every character in the path costs density.
Every incumbent gets identity wrong, Jitsi and Slack included: it is per-connection or per-login, so the same person on a laptop and a phone is two tiles, two names, two mute buttons, and two unread counts. KithMoot groups by participant instead of by connection. A device is a credential a person holds, and a room knows the person.
Every incumbent also has an operator. Slack holds the member list and the
history; a Matrix homeserver holds the room state; a NIP-29 relay holds the
group. KithMoot has none. Membership is who holds the key, and the key is
handed out by whoever holds the link, so there is no server whose removal
takes the workspace with it, and no server that can read it. The trade-offs
that buys are written down in docs/decisions.md, starting with why this
is not built on NIP-29 or on Marmot.
And every incumbent bolts agents on through an operator's API. Here an agent is a member on the same terms as a person, with one addition: it says it is one, and it proves whose it is.
Today: groups that stay open with nobody online, channels inside each,
threads, edits and retractions, mentions on the wire, direct messages, read
positions that follow you between devices, encrypted files, calls with
screen share, and agents as attested members, including a scribe that
writes the minutes. Not yet: a frozen protocol, history beyond thirty days
and five hundred messages, search beyond what a tab has loaded, and a push
to a phone in a pocket. Those are next, in that order. CHANGELOG.md
records what has shipped and
what does not work yet is the honest list.
The link is an admission capability, not the room's traffic key. It stays in the URL fragment, proves itself over an encrypted relay rendezvous, and pins a fresh inviter identity so another link holder cannot substitute a room of their own. The room creator can rotate it without changing the live room. Relays still see opaque room/rendezvous identifiers, event timing, volume and device keys. They do not see room contents, participant identities or names from those events. KithMoot reduces metadata; it does not pretend metadata does not exist.
The fragment protects the invitation from KithMoot's HTTP server and ordinary link-preview fetchers, not from the service carrying the message. In an end-to-end encrypted conversation that service cannot read the link; in a cloud-hosted group it can. Treat the messaging group's confidentiality as part of the invitation's threat model.
No operator is mandated, which is not the same as no infrastructure. Signalling rides Nostr relays that already exist, and media goes device to device with nothing in the middle. But NAT is a fact: two people behind symmetric NAT or carrier-grade NAT cannot reach each other directly, and a call has no origin to fall back to the way a stream does. Roughly one connection in five cannot be made directly.
What a symmetric-NAT client can always do is connect outbound to a peer with a public address. So a pair that cannot reach each other is offered, in order: a member of the room who volunteered to carry them, then a forwarder the room names, then TURN. Most of that one-in-five is somebody in the room being publicly reachable and willing, and only what is left over reaches a server anybody pays for.
A room still names its own STUN and TURN servers, the way it names its relays: a plural, swappable list. Self-host coturn, point at somebody else's, or name none and accept that some pairs will not connect. Nobody is required, and no single party can be removed to take the system down. That is the claim, and it is narrower than "nothing to run".
Encrypted personal, Kin and Kith context is available in Room details → Context, with the same MIT library exposed through CLI and MCP. See the context guide for grants, explicit sharing, backups and current limits.
Den can link a private task to shared room work, with agent execution, questions, handoffs and exact-result review. See shared assignments with Den for the supported flow, worker tools, verification and recovery limits.
-
Room creation and one-tap join by URL. New links carry a bearer invitation and a pinned, per-link inviter pubkey in the fragment, never the room traffic secret. A response returns that secret encrypted only to the requesting browser together with a short-lived, creator-rooted delegation. Every admitted web or Android client then answers the same link, so the creator may leave while the remaining room keeps admitting arrivals. Existing v1 room-secret links remain readable during migration.
-
Invitation rotation. The creator publishes a durable signed tombstone so online delegated responders stop answering and offline ones retire the old link when they reconnect; the live room and everybody already in it stay put. Rotation is cooperative link retirement, not member revocation, and the UI says so.
-
Kindred-gated access tiers (
open/ken/kith/kin), built on thekindredprimitive. A room can admit anyone with the link, or require proof of anything up to a mutually-verified bond. -
A participant-grouped roster: every device's presence is grouped by who it belongs to, and singular roles (
mic,monitor) are arbitrated deterministically so two devices under one identity can never both claim the live microphone. -
A speaking indicator, grouped the same way: a person's tile lights when either of their devices makes a noise, so the two-device claim holds here too. Energy off an
AnalyserNode, with two thresholds and 400ms of hangover so it rides the gaps between words instead of strobing through a sentence. Your own tile lights as well, because a person should be able to see they are being picked up rather than guess. Muting is instant: the track stays published withenabled = false, which feeds the analyser silence. -
"Is this really them", asked in the call. A name is a claim and nothing checks it - which on 3 September 2026 let three participants in a room claim names belonging to somebody else while everybody read the pubkeys and could not tell. So each pair is shown three words derived from the room key and the two participant keys, directional so the second speaker cannot pass by repeating the first. Say them out loud, where you can see the person and hear their voice; the call is the strongest out-of-band channel anybody gets. Mark them verified and this device remembers the key. A familiar name arriving on a different key afterwards is flagged in red, which is the only part of this that catches an impostor - the words prove that both clients see the same pair of keys, not that somebody already in the room could not compute them.
-
Mesh WebRTC for video, voice and screen share, negotiated directly between devices with no media server in the path. SDP/ICE signalling travels wrapped in a NIP-59-style gift wrap addressed to one peer, so a relay carrying it never reads it.
-
Room-key-encrypted chat (NIP-44), durable across a relay restart and independently admission-checked. Clients request at most the last 30 days, retain at most 500 messages, cap text at 2,000 characters and accept at most 30 messages per sender per minute. A relay may retain old ciphertext and its public room id longer; client retention is not remote deletion.
-
A file shared through Wildbloom can ride with a chat message: its kind-1063 event id, Blossom URL, hash and recovery key travel inside the room-key ciphertext, and nothing is fetched until a person clicks. Drop a file on the chat and the browser does the Wildbloom part itself: seals it under a fresh key, puts the envelope on the Blossom server this device names, announces it with a kind-1063 event signed by the device key, and stages it for the next message.
-
Names, and optionally a real Nostr identity. Type a name and join, or sign in with a key you already have. See below.
-
Search a conversation. Open Room details and choose Search this conversation to find words, people or file names in the history already loaded in this tab. Files only lists messages with shared files, and selecting a result returns to the original message. Search stays on the device, downloads no files and keeps the current call and unfinished message in place. It covers the active conversation's loaded history, bounded by the 30-day query window and 500-message retention cap; it does not query a separate archive or search inside files.
-
Drafts stay with their conversation. Switch between Chat, Agents and named conversations without carrying unfinished text or files into the wrong place. An upload finishes in the draft it started in, and can be stopped. Room details marks conversations with drafts and lets you discard the current one. A closed conversation keeps its draft available to copy. Drafts stay in this tab's memory; leaving or reloading discards them, with a warning before a normal browser navigation.
-
Leaving is instant, and so is a dropped connection coming back. A device that hangs up, closes the tab or navigates away says goodbye, and everybody else drops it at once rather than after the presence timeout. A connection that was up and reports
disconnectedis given a few seconds and an ICE restart before anybody gives up on it, the way Jitsi and Signal ride out a Wi-Fi blip, and a relay that drops its socket is re-dialled with every subscription re-issued. -
A call that came up stays up. Presence is judged by when this device last heard from another, not by the sender's clock, so a phone whose clock runs slow is not evicted on arrival and re-admitted twenty seconds later. A device whose media is still flowing is a device that is here, whatever the relay has carried lately: a backgrounded tab, a relay that drops its socket, a phone crossing between cells no longer close a peer connection that is carrying perfectly good video. A pair that has run out of rungs on the route ladder rests and tries again from the top rather than staying dark for the rest of the call, and a pair with nothing to carry never walks the ladder at all.
test/soak.spec.tstakes the relay away for ninety seconds under a live call and requires the picture to keep moving; before these fixes the far tile went at seventy-six seconds. -
Persistent groups. New web rooms default to a group that people can return to without a keeper. Its version 3 invitation is signed by the creator and stored encrypted on the relays before the share link is offered. New arrivals can join with every member offline. Membership and creator authority stay on this device until forgotten; an expiring device credential is renewed separately. Choose Temporary meeting for the original live invitation, or Keep as a group in Room details to convert a meeting you created. Share the updated group link after conversion. Anyone holding a group link and its stored invitation can learn its original traffic key and shared history. Link replacement stops admission in cooperative clients; it cannot erase copied keys or remove a member. Relays still need to retain and serve the encrypted invitation and chat. V3 needs an updated client; the separate Android app has not been updated by this change. See
docs/persistent-groups.mdfor the protocol and limits. -
Long calls and managed rooms. A primary device re-mints its credential halfway through its twelve-hour life and restates itself, so a room left open for days does not lose every member at the twelve-hour mark. A joined page re-fetches its TURN credential every forty minutes, so a pair that needs a relay two hours in is not built from an expired one. A pair that cannot connect rests longer each time it is retried, from thirty seconds to ten minutes, rather than being renegotiated every half minute for ever. An entry a relay replays from before the presence window is refused at the door, so a joiner is not shown the ghosts of devices that died without a goodbye. A keeper (
kithmoot-agent create) holds the root inviter key and admits people for as long as it runs; its state persists across restarts, so the same link reopens the same room, anddeploy/keeper-deploy.shinstalls one as a service. Keepers remain useful for managed-room services such as removal, named channels and nudges; basic persistent group admission does not require one. -
Your rooms. Sign in with Nostr from the website or app home to see your account's room bookmarks across browsers and devices. Rooms opened while signed in are encrypted to your own key using your signer's NIP-44 support, then saved as separate kind-30078 records. Visitor history is not imported automatically; Add rooms from this browser asks before saving existing shortcuts to the account. Visitors can still open or bookmark a direct link without signing in. Signers without encryption get clearly labelled local-only bookmarks. Failed saves remain local with an explicit retry; an accepted save means at least one relay acknowledged it, not guaranteed permanent storage. Names and links are encrypted; relays still see the account's public key, KithMoot use and update timing. Device credentials, read positions and the choice to keep an admission are not synced. Signing out hides that account's list, but does not erase the browser's local cache; do not treat sign-out as a shared-device data wipe.
The home page puts saved rooms first, with a search by name or room code. Start a named room or paste a complete invitation below the list; sign-in is optional. Invitations from another host can open in this app with their relay and admission settings intact. Incomplete links stay beside the input for correction. An unanswered invitation offers a retry, and a retired invitation asks for a current link. Joining keeps camera and microphone off and lets you retry a failed connection with your name still in place.
The Rooms button in a conversation opens a searchable picker without leaving the room or interrupting a call. Pick another room to enter its conversation directly, still subject to admission and restoring the same account. Switching away from a live call asks first and starts the next room with camera and microphone off. With unfinished messages or files, use New tab to keep that work in its original tab. All rooms / Start a room in the picker opens the full dashboard. Ctrl K / ⌘ K opens the picker from a room; its search field is ready to type in. Returning to a room in the same tab restores its last conversation when still available.
On a desktop, a sidebar keeps saved rooms visible beside the conversation. Use ⋯ beside a room to assign a project. The mobile picker and home list offer the same Project action and a project filter. Search matches project names as well as room names and codes. Project labels stay on this browser, separate for each signed-in account and visitors; they do not sync or change room membership.
Chat, Agents, Transcript and Minutes are visible above the messages, alongside named conversations announced by the room. Unread badges count other participants' loaded messages not yet read in this visit; opening a conversation at its latest messages clears its badge. They do not report activity in other rooms. Watch agents opens their shared conversation, where messages arrive as they are sent and people can join in. The presence summary shows who is here without claiming that an agent is thinking or working. Invite an agent opens the available hosts; when none are offering agents, it explains why and links to setup instructions.
Without sign-in, the front page lists the rooms this browser has been in, by the name on their link, with how many chat messages are newer than this device last read and who is in the room now, agents marked. The counts are read off the room's relays without joining and without publishing anything, using the key this device already holds: a room it created, for twelve hours; a room it was admitted to, for the tab's session. A room whose key it does not hold says so, and opening it is what requests admission again. A bookmark is not permanent access: an invitation may expire or be retired, and a member may need to be online. Open uses the invitation in this app, not a redirect to a bookmark's original host. Forget removes a visitor bookmark on this device, or publishes an encrypted tombstone for a signed-in account's bookmark. It does not revoke room access or erase old relay copies. A room can be named when it is started, and the name rides in the link, so every device that opens it calls it the same thing.
-
A member can be removed, and the room closed. Rotation retired a link; it never removed anybody, because everybody admitted held the room key. Now a keeper can move the room to a new epoch: a fresh secret, sealed to every remaining device, from which the roster, chat, channels, descriptor and media keys are all derived afresh. A removed member keeps what they already read and decodes nothing after; the keeper refuses them the new epoch on the credential that proves who they are, and everybody else sees "X was removed by Y" in the chat.
kithmoot-agent create --admin <pubkey>names who may ask, and the app shows those people a Host panel with Remove, Mute and Close room. Mute is a request the other client honours; Remove and Close are enforced by the key. Seedocs/decisions.md. -
A stopped speaker does not stop the microphone. The masking graph is clocked by the machine's audio output device; when that device stalls, the graph runs and produces nothing, and nobody hears you. The pipeline watches its own clock and hands the raw microphone over when it stops, saying so in red. Masking is lost; the voice is not.
-
Agents, as members.
kithmoot-agentjoins a room from the same link a person was sent, with no browser involved: in the roster (markedagent), in the chat, on a channel of their own that every person can read, and, when a person allows it, on the end of their microphone with WhisperX writing what they said into a transcript channel. Driven by a pipe, by a local model through Ollama, by Claude, or by any MCP client. Seedocs/agents.md. -
Whose agent is this. A principal signs, once, that an agent is theirs (
kithmoot-agent attest), and the agent carries that proof on every roster entry and message. Every reader verifies it, so "Tally, agent of Ada" is shown only from a proof the reader checked itself; an agent that merely says it is somebody's gets the plain badge. A room's link can require it:agents: 'owned-by-members'admits an agent only with a proof from a participant who is in the room. Seedocs/agents.md. -
Approvals in the room. An agent about to act can ask - on the control channel, where everybody sees the question - and the person who can answer sees a card with a button per option. Only an announced admin or the agent's verified principal counts; anybody else's click is ignored and the agent is told. Everybody sees "Ada approved Tally's request". Through the stdio brain as an event, and the MCP brain as a tool.
-
Invite an agent with one click. An agent host (
kithmoot-agent host) sits in the room with a catalogue of personas; every person sees the catalogue through Agents → Invite an agent and starts or stops one with a button. The agent runs on the host's machine and joins through the room's link like anybody else. -
Minutes, with nobody typing them.
kithmoot-agent scribesits in a room listening and writes minutes into aminuteschannel when anybody types!minutesor when the call ends: attendees, decisions, actions, open questions, from a local model or Claude, or the transcript grouped by speaker with no model at all. Nothing from anybody whose Agents can hear me is off. Seedocs/agents.md. -
Agents can hear me, a switch in every browser, off by default. Off means this device's camera and microphone are never handed to a connection to anything that says it is an agent: the media does not leave the device for them. A conversation people want among themselves is one no agent in the room can hear.
-
A fresh device key for every room. A relay learns the device keys in a room from the roster events it carries; one key across every room a browser ever joins would let it follow one person from room to room. The participant key, the one that identifies a person, only ever rides inside the room-key ciphertext.
-
An installable PWA. Add it to a home screen or dock, and a service worker carries the shell offline. While open, it checks for updates every minute and when you return or reconnect. An Update ready banner offers Reload to update; calls and unfinished messages ask for confirmation.
-
Background blur and replacement, on by default the first time you turn the camera on. Read the honest limits below before trusting it.
-
Voice masking: pitch and formants shifted independently, in four presets. It defeats casual recognition and nothing more. Read the limits.
-
Forwarders, so a room can outgrow the mesh. The room names them the way it names its TURN servers: a plural, swappable list. Promotion happens on measured capacity, never on headcount: two people sharing legible 1080p screens can need a forwarder while twenty on audio-only do not.
-
Media a forwarder cannot read. A forwarder is given the room id and never the room key. Media is encrypted under a separately derived key, so it routes ciphertext it cannot decrypt and cannot forge attribution for.
-
Peer assist, so a room's spare uplink comes from the people in it. A member that measures itself publicly reachable can offer to carry the pairs that cannot connect directly, and the room tries that before any server. It is opt in, revocable mid-call, never suggested on a phone or on battery or on a metered connection, and capped so volunteering cannot ruin the volunteer's own call. A fixed relay has fixed capacity; a room whose members carry it gets more capable as more people arrive.
-
A native Android app (
forgesworn/kithmoot-android), a second independent implementation, written against the published vectors without reading this codebase. It reads the agent flag, honours the "agents can hear me" switch and carries display names. It cannot yet follow a room epoch, and says so rather than going quiet: see its own README for what it does and does not implement. -
101 published interop vectors (
vectors/), which both implementations are checked against.
Two ways in, one participant model.
Type a name. The default, and the whole of it: the app generates a participant key, you type what people should call you, and you join. The name is self-asserted. Anyone can type any name, including yours, so it never stands alone. A short pubkey renders beside it everywhere, on tiles and in chat, and the full npub is on the element's title. Two people who both typed "Robin" are visibly two people.
Sign in with Nostr. Available at the room entrance and on the account
home. Uses signet-login
for the whole picker (NIP-07 extensions, NIP-46/NostrConnect, bunker URIs,
Amber on Android), and the participant key becomes
your real Nostr identity, held wherever it already lives. Behind Advanced
there is also the dangerous option: paste an nsec, or a password-protected
ncryptsec (NIP-49)
that is decrypted in the browser once you enter its password. That key is
held in memory for the page only, is never written to storage, and is gone
on reload. The entrance shows
the chosen key and explains that entering a name alone creates a separate
visitor identity. See Your identity in KithMoot if an
agent does not recognise you.
This is also a security improvement. On the signer path there is no
secret for the signed-in Nostr account in localStorage. An older visitor
key can remain in the browser, but it is not used while signed in. The reason it works is that
the participant key signs exactly one thing: a device credential, one small
event per room. Everything else already runs on other keys: the device key
signs the roster and the gift-wrapped signalling, the room key encrypts the
roster and the chat. So the whole surface an identity has to cover is a
pubkey and an async signEvent, which is
ParticipantIdentity in src/identity.ts. A locally generated key and an
external signer both satisfy it, and nothing else in the protocol can tell
which it has.
A signer that can prove who you are but cannot sign afterwards (an auth-only session) is refused with a reason, because a room needs that one signature per join.
- A typed name is a claim. Nothing checks it. That is the point of the zero-friction path, and it is why the pubkey is always beside it.
- A
nostrchip means the key has a published kind-0 profile. That is the only signal that exists. Nothing on the wire distinguishes a real Nostr key from one this browser generated a moment ago, and nothing could. - Public-profile lookup is on by default. Room details offers a switch remembered on this device. Looking profiles up gives the room's relays the participant keys in plaintext queries, and loading pictures contacts their hosts. NIP-05 addresses are checked with their domains and shown only when the address maps to the profile key. Turning it off stops further lookups and removes the loaded profiles; it cannot retract requests already sent. Other members can independently enable lookups, so this is not a room-wide privacy guarantee.
- A kind-0 name is also self-asserted. It says "the holder of this key calls themselves Robin", which is the same kind of claim as a typed name; the difference is that the key is persistent and has a history. It is never labelled "verified", because it is not.
- Names are sanitised at both ends.
sanitiseDisplayName(src/display-name.ts) strips every Unicode "other" character (controls, bidirectional overrides, zero-width padding), collapses whitespace, and caps the result at 32 code points. Applied on encode so this client never publishes a hostile name, and on decode because no other client is obliged to have bothered. Markup is deliberately kept as literal text: what makes it safe is that a name never reachesinnerHTML, and there is a guard test for that rather than a filter pretending to be one. - A name is inside the ciphertext. It rides in the room-key-encrypted roster alongside the participant pubkey, so a relay carrying a room cannot read its guest list by name any more than it can by key.
| Video | Voice | Chat | Screen share | |
|---|---|---|---|---|
| Desktop browser (Chrome, Firefox, Safari) | yes | yes | yes | yes |
| Android (native app) | yes | yes | yes | yes |
| Android (browser / PWA) | yes | yes | yes | unreliable |
| iOS / iPadOS (Safari or PWA) | yes | yes | yes | no |
There is no iOS app. The web app loads on iOS and video, voice and chat
work, but getDisplayMedia does not exist on iOS Safari, so screen
sharing is impossible from an iPhone or iPad in any browser, including
Chrome and Firefox for iOS, which are Safari underneath. Sharing an iOS
screen needs a native app using ReplayKit, which is not built.
This is also why the Android client is native rather than a browser tab: mobile browsers cannot reliably share a screen, and screen sharing is half the point.
Blur is on by default the first time you enable your camera, and the
control to turn it off sits directly under the camera toggle. That default is
one constant, BLUR_ON_BY_DEFAULT in src/video-effects.ts. The reasoning:
the failure mode of blur-on is a slightly soft background and some battery,
and the failure mode of blur-off is publishing your living room to a room of
people, which is a thing that has actually happened to this project.
What it does not do:
- Segmentation is a guess. It is worst at hair, at held objects, in low light and under fast movement, and every one of those failures publishes a piece of the real room for a frame or two. Treat it as making a room harder to read, not as a guarantee nobody can see it.
- It costs CPU and battery. Measured on an M4 Max in Chromium at 640x480 and 30fps: 8.2ms of work per frame and 28% of the renderer's main thread, against 0.3ms and 3% with the effect off. A slower machine will drop frame rate before it drops the effect.
- It is a first-use download. MediaPipe's segmenter is 11.7MB of WASM (3.4MB gzipped) plus a 250KB model. Nothing is fetched until you turn an effect on, and it is served from the same origin as the app rather than from Google's CDN, so enabling blur does not announce you to a third party.
- If it fails, the camera passes through unmodified rather than going black or taking the call down, and the control says so in red. That is deliberate: a broken effect must not end a call. It does mean a failure shows the room, so the message is worded to be acted on.
Background replacement offers three bundled abstract images. There are no uploads, because a user-supplied picture is a file-handling surface and can itself leak - a holiday photo, an office, a name badge.
The risky moment is a camera flip or a device change, where a naive
implementation publishes a few hundred milliseconds of unblurred frames.
KithMoot publishes the canvas rather than the camera, so a flip changes only
what feeds the canvas: the published track is the same object before and
after, nothing renegotiates, and the effect is told the source is changing
before it changes. From that instant every frame is blurred whole until the
new camera produces a mask. test/effects.spec.ts swaps the camera four
times and asserts that not one frame took the unmodified route.
It is masking, not anonymity. It shifts your pitch and moves the resonances of your voice separately from it, which is enough that someone who knows your voice will not place it straight away.
It will not stop anyone holding a recording of your voice who wants to match it, and it does not survive a forensic comparison. Do not lean on it where being identified would matter. The words "anonymous" and "unidentifiable" appear nowhere in the interface, deliberately.
Six presets - off, lower, higher, neutral, deep, bright - rather than free sliders, so the result is predictable and every option stays intelligible on a bad line. Deep and bright push the same trick as far as it goes: more disguise, less like a person. "Hear yourself" records three seconds of the outgoing audio and plays it back, so you hear what the room hears rather than what your own skull tells you.
Measured added latency, at 48kHz: 0ms on off (a real bypass, not the vocoder configured to do nothing) and 16ms on every other preset (15.5ms to 18.5ms by onset measurement, against a 16.0ms algorithmic figure). The budget was 50ms. Formant shifting is what makes this more than a chipmunk: a pitch shift alone is undone by shifting back, whereas moving the excitation and the envelope by different factors is not a one-parameter inverse.
Stated plainly, before anyone else finds it. The workspace gaps come first, because a team is what this is now judged against:
- The message layer is on the wire and not yet frozen. Replies and
threads, edits, retractions, mentions, direct messages and read positions
are fields of the chat payload, vectored and read by both clients; see
docs/messages.md. The kinds are still provisional, the legacy name-match for a message with nomentionsfield is still applied, and Android reads every shape but writes only the ones it always did. Threads are one level deep on screen: a reply to a reply sits in the same thread and says which message it answers. - History is a window, not an archive. A client asks for thirty days and keeps five hundred messages, edits and reactions included, and search covers only what the tab has loaded. A workspace ninety days old cannot show its first message.
- No push. A phone in a pocket learns nothing until the app is opened. A keeper can nudge a signed-in member over Nostr, and that is all.
- Named channels, removal and roles need a keeper. A group admits and chats without one, but creating a channel, removing a member and naming an admin are keeper commands, not something a member does from a phone.
- Read positions follow a signer, not a visitor. They sync between devices that hold the same Nostr identity with NIP-44, the way bookmarks do. A visitor's key lives in one browser, so its positions stay there, and a paired secondary device keeps its own.
- A retraction is cooperative. Every device that received the message keeps it, and the relays keep the ciphertext; the interface says so.
- No iOS app. See above. It is the largest gap.
- Forwarder trees are two levels deep. Enough for a room of about 21;
beyond that nobody has measured anything. One process can now serve several
rooms (
KITHMOOT_ROOM_IDtakes a list, with a key derived per room so they do not share a pubkey), but they share an event loop and an uplink, so that is for a box carrying several quiet rooms rather than a way to scale one. - No browser-as-forwarder. It needs WebRTC Encoded Transform, which is solid in Chrome and patchy in Safari, so it stays opportunistic and never load-bearing. The reference forwarder is a small Node process.
- Android consumes forwarders, it cannot act as one.
- Encrypted media costs an extra encode/decode pass and interacts badly with some hardware codec paths. It is only needed once a forwarder is in the path; pure mesh is already end-to-end via DTLS-SRTP.
- Kind numbers are provisional (
src/kinds.ts) and will change once the spec is written. - No background effects or voice masking on Android yet. The web app has both; the native client does not. The Android client is the one most likely to be used somewhere sensitive, so this is the gap that matters most.
- No face blurring, and no redaction of other people in shot. The segmenter finds one person and treats everything else as background, so a second person behind you is blurred rather than hidden.
- No user-uploaded backgrounds, on purpose. See above.
- A device that keeps media from somebody never uses a forwarder. A
forwarder fans out one copy to everybody it carries for and cannot be told
to skip anyone, so a device whose person has switched agents off stays a
mesh and pays
(N-1) x bitrateitself. That is the price of the promise, and only the device that made it pays: everybody else in the room promotes normally. It does mean a room of people who all switch agents off does not get a forwarder at all. - WhisperX is not bundled.
server/whisperx/is a Python server that needspip install whisperx; the Node side is checked against a fixed transcriber. - An agent cannot speak aloud. It reads, writes and listens; a voice
would be a text-to-speech track behind
publishTracks, and is not built.
npm ci
npm run build:lib # build the library and declarations in dist/
npm test # unit and protocol tests, in-process relay simulator, no network
npm run test:live # wire format against real public relays
npm run test:e2e # the acceptance tests, in a real browser, against the test
# relay in test/ws-relay.mjs: deterministic, offline, the gate
E2E_RELAYS=live npm run test:e2e
# the same against real public relays: the live check. Expect
# it to fail sometimes on relay latency alone. That is
# weather, not a regression - never read one red run as a bug
npm run typecheck
npm run demo # HTTPS dev server for driving the app by hand, phone included
npm run build # production PWA build, to app/dist
npm run agent -- --help # kithmoot-agent: be in a room without a browsernpm test builds the library automatically through its pretest script.
The forwarder and some acceptance fixtures import from dist/, which is
not committed; build the library first when running those files directly.
npm run test:live needs the network, and real relays have real weather, so
it is excluded from npm test. npm run test:e2e drives real browsers and is
excluded for cost rather than weather: it runs against a NIP-01 relay of its
own (test/ws-relay.mjs, started by playwright.config.ts), which is what CI
runs and what a red run can be believed on. E2E_RELAYS=live points the same
specs at real public relays for the live check. Two or three browser contexts in a room, measured off
the decoded pixels and the audio energy rather than the DOM. That gate
exists because the unit suite once passed 685 tests while the shipped app
negotiated media perfectly and put none of it on screen. test/soak.spec.ts
is the other half of that question - whether a call that came up stays up -
and test/agent.spec.ts puts a Node agent in a browser's room over a real
relay socket and a real WebRTC stack. The relay also
takes RELAY_OK_DELAY_MS, which delivers an event at once and acknowledges
it late: a slow public relay made deterministic, which is how the case where
the joiner could not see or hear whoever started the room is pinned down on
a loopback socket that would otherwise never show it.
npm run demo and vite preview both serve the app under /j/ rather than at
the root, because base in app/vite.config.ts matches where it is
published. The asset URLs, the web manifest and the service worker's scope
all derive from it.
npm run demo serves the app over HTTPS with a self-signed certificate
(@vitejs/plugin-basic-ssl): getUserMedia and getDisplayMedia both
require a secure context, and a phone reaching your laptop over its LAN IP
isn't one without TLS. Your browser will warn about the certificate; accept
it to proceed. The terminal prints a Network: URL for the phone to use, with
the /j/ already on it.
nostr-tools versions >=2.23.11 <2.24.2 silently killed long-lived
subscriptions
(nbd-wtf/nostr-tools#539),
and a conference room is nothing but long-lived subscriptions. That issue
is closed, and we now depend on ^2.25.0 with no exact pin. A test in
src/nostr-tools-version-guard.test.ts still fails the build if a future
install resolves back into the broken range.
This is the whole of the product's claim, checked by hand and, since stage 2,
repeated on every run by npm run test:e2e, and against real public relays
by E2E_RELAYS=live npm run test:e2e:
- On a laptop, open the app, choose Start a room, enter your name and choose Go in. Open Call, then Screen share, and pick a window.
- Open Room details, then Add a device. Copy that link, not the plain room link above it, and send it to a phone. Leave the laptop page open: it is what answers the phone, and closing it retires the link.
- On the phone, open the pairing link. The laptop asks you to confirm the new device; say yes. The phone then waits for its credential: Go in stays greyed out until it arrives. Once available, choose Go in, open Call, then turn on Microphone and Camera.
- On a third browser (a different device, or a private window with a fresh profile), open the plain room link, enter a name and choose Go in. Open Call to see the devices together.
The pairing link grants a credential for that room only, expiring after
twelve hours. It does not carry your identity. The participant key never
leaves the laptop, and if you signed in with Nostr it was never on the
laptop either, so a pairing link that goes astray costs one room for an
afternoon, not your Nostr identity for ever. Step 4 comes last on purpose:
the roster rides an ephemeral kind, so the third browser learns who is
already there because they answer its arrival, not because a relay stored
anything (see docs/decisions.md).
Expected, on the third browser: one tile group, reading "2 devices" with a "one person" badge, carrying live screen video from the laptop and live camera video and audio from the phone. Muting on the phone mutes you everywhere. Nothing echoes, because only one device holds the mic role at a time. That tile is the product; everything else on the page is scaffolding to show it.
Live at kithmoot.forgesworn.dev, served by
Caddy from a Hetzner box. deploy/deploy.sh builds the app, assembles site/
at the root with app/dist under j/, rsyncs it into a timestamped release
directory and flips a current symlink at it. A rollback is one symlink
change; nothing is ever built in place, and nothing is deleted without
--prune.
DEPLOY_HOST has no default and the script refuses to run without it. That is
deliberate: this repository is public, so the box's address is not written down
in it, and there is no host to deploy to by accident.
export DEPLOY_HOST=deploy@your-box # required; no default
deploy/deploy.sh # build, ship, flip the symlink
deploy/deploy.sh --install-caddy # also install the vhost, validate, reload
deploy/deploy.sh --dry-run # build and assemble, touch nothing remote
deploy/deploy.sh --prune 5 # keep the five most recent releasesThe published layout:
/var/www/kithmoot/
releases/<ts>/ one deploy: site/ at the root, app/dist under j/
current -> releases/<ts> what Caddy's root points at
apk/ outside the releases, so a rollback of the site
does not take the downloads with it
deploy/Caddyfile.kithmoot is the vhost, installed as an additive drop-in at
/etc/caddy/conf.d/kithmoot.forgesworn.dev.Caddyfile. It never touches another
tenant's directory, unit or vhost, and --install-caddy validates the whole
config before reloading, because a broken drop-in would take every site on the
box down with it.
Two things in there are load-bearing and easy to get quietly wrong:
- The app's headers are set by a matcher, not by overriding a base block.
A base
headerblock containing-Serveror a?set is deferred by Caddy to response-write time, so it runs after every matched block whatever the file order says.Content-Security-PolicyandPermissions-Policyare therefore set by two blocks with disjoint matchers,/j /j/*and everything else. Get this wrong and the app inherits the marketing page'sdefault-src 'none'andcamera=(): it loads as a blank page with the camera disabled, and nothing in the server logs says so. - The app's CSP is derived from what
app/src/main.tsdoes, not copied from another site. It needsconnect-src wss:for relays a join link names,img-src https:for kind-0 profile pictures, andstyle-src 'unsafe-inline'becausesignet-loginbuilds its signer picker with a<style>element.
DNS is a plain A record for kithmoot in forgesworn.dev, grey cloud rather
than proxied, so Caddy issues and renews the Let's Encrypt certificate itself.
The APKs are served from /apk/, with kithmoot-latest.apk symlinked at the
newest so the page never needs editing when a build lands. deploy.sh picks up
whatever is under kithmoot-android/app/build/outputs/apk/ and skips cleanly
when there is nothing there. No APK is committed to this repository.
MIT
The M2 protocol draft freezes the existing wire and documents the additive signalling profile, reserved service codecs and compatibility gate. Kind registration and service enforcement remain separate.