Rooms · Direct messages · Attachments · Multiple identities
No server. No account. No directory anyone can look you up in.
An open-source project by N3XT Agency
Overview · Install · Interface · Accounts · Security · Architecture
openchat is a terminal chat client with no server anywhere in it — not one you run, not one we run, not one you have to trust. Members find each other on a distributed hash table, hole-punch a direct connection, and replicate an encrypted append-only log between themselves.
An account is a keypair on your machine. There is nothing to sign up for, no directory to be listed in, and nobody who can enumerate users, read a room, or lock you out — because there is nobody in the middle at all.
| Identity | An Ed25519 keypair on your machine, backed up by 24 words |
| Discovery | Hyperswarm DHT — no bootstrap server of ours, no account server |
| Encryption | AES-256-GCM bodies, Ed25519 signatures, verified before decrypt |
| Rooms | An Autobase of per-member logs; offline members replay what they missed |
| DMs | Derived from two public keys — no invite, and the topic itself is secret |
| Interface | A full-screen, keyboard-first modal TUI: conversation list, chat, floating windows |
| Requirements | Node.js 22 or newer. Nothing else. |
| Feature | In one line | |
|---|---|---|
| 🔐 | End-to-end encryption | Every message signed and sealed; the room key never touches the network |
| 🛰️ | Serverless transport | Peers find each other on a DHT and replicate directly, with nothing in between |
| ⌨️ | Modal, keyboard-first UI | LazyVim's bindings, and a key menu so none of them need memorising |
| 🔎 | Fuzzy finders | Rooms, people, members, commands, keymaps, accounts and message search |
| 👤 | Multiple accounts | A username and a keypair each, sharing nothing — switched without leaving the app |
| 💬 | Rooms and DMs | Invite-based group rooms, and direct messages that need no invite at all |
| 📎 | Attachments | Metadata in the log, bytes on demand, checksum verified on arrival |
| 🎨 | Themes and settings | Eight real terminal palettes, stored per account, applied as you change them |
| 🖱️ | Mouse where it helps | Clickable floats and conversation list; off elsewhere so selection still works |
Node.js 22 or newer, and nothing else. openchat is built from source — there is no package to install yet.
git clone https://github.com/n3xtpy/Openchat.git
cd Openchat
npm install
npm run build
npm link # optional: puts `openchat` on your PATHWithout npm link, run it as node bin/openchat.js wherever the docs below say
openchat.
openchat # the account you used last
openchat work # an account called work, created if it is newThat is nearly the whole command line. openchat is an app you sit inside, not something you drive one shell invocation at a time — rooms, invites, direct messages, contacts and everything else happen in the app. The one thing the shell decides is which account you are, because an account can only be open in one process at a time, so it is the first argument.
On first run it sets up an account, which here means generating a keypair. There
is nothing to choose: a key is generated on the machine that uses it, and openchat
has no way to bring one in from anywhere else. It then shows you a recovery phrase —
c copies your public key, shift-c copies all 24 words — and :backup is the only
other place you will ever see them.
Write them down. They are the seed your identity is derived from, and there is no server that can reset anything for you. Be equally clear about what they are not: openchat never reads a phrase back, so losing this machine loses this account.
Then you land here — who you are, what to do first, and the keys that get you there, sitting in the middle of the pane:
██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ ██╗ █████╗ ████████╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██║██╔══██╗╚══██╔══╝
██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ███████║███████║ ██║
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██╔══██║██╔══██║ ██║
╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗██║ ██║██║ ██║ ██║
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
end-to-end encrypted · no server · no account · v0.1.0-alpha.1
╭─ ada ─────────────────────────── ◆ default ─╮
│ 22f071ec5f9f80f4739edd56… │
╰─ ␣ k your keys, and the phrase behind them ─╯
START HERE
1 ␣ r n make a room — you own it, and nothing anywhere lists it
2 ␣ r i share its invite out of band: the invite is the room key
3 ␣ f d or skip rooms and message anyone by their public key
KEYS
␣ every key, in a menu ␣ f f find a conversation
? the whole keymap ␣ f s search what was said
: a command ␣ k your keys
· you both have to be online — nothing is held anywhere in between
The whole block is centred as a block — one indent for every row, so the card, the numbers and the two key columns keep the edges they were laid out against. The name comes in three sizes and takes the largest the terminal can hold: these block letters, the same word in three rows of half blocks, and below that a single line of text. The card wears the same chrome as every window in the app, because that is what it is — a fact about you, in a frame.
Then, inside:
:new design-team open a room you own
:invite its invite string, in a window — share this out of band
:join openchat1:AUEp… join a room someone invited you to
:dm 03d35f4c5d0f36a0… message someone directly, using only their key
:help everything else
: opens the command line, the way it does in vim — the message box only ever
sends messages, so nothing you type into it can be mistaken for an instruction. Or
press space and read the menu. esc and i move between normal and insert mode,
and nothing is reachable only by chord: every window has a command too.
Send invites out of band — a call, a QR code, a channel you already trust. An invite is not a link, it is the key to the room: anyone holding it is a member. One of you also has to be online while the other joins, because admitting a member is something an existing member does and there is no server to do it for you.
Two ideas, taken from the two terminal programs this is meant to sit next to.
|
openchat takes the whole terminal — the alternate buffer, the one Nothing scrolls away. The transcript scrolls inside its own pane ( |
|
A floating window is composited over that frame rather than pushed above the prompt: it takes the rows it needs out of the middle of the screen, and the conversation stays visible above and below it.
▌ #design 3 members · yours ● connected · 3 peers
ROOMS ─────────── │ · bob joined
▌ #design ★ │
#ops │ 20:06 ● bob │ got the invite — this is over the
DIRECT ────────── │ │ DHT, no server anywhere
@grace 2 │
│ 20:06 › you │ that is the idea. offline members
│ ╭─ ⌕ Conversations ─────────────────────── 1/4 ─╮
│ │ ⌕ dsg │
│ ├───────────────────────────────────────────────┤
│ │ ❯ #design here · yours │
│ │ @grace 2 unread │
│ ╰─ ↑↓ move · ⏎ open · esc close ───────────────╯
│
╭─ #design ────────────────────────────────────────────────────────────────╮
│ ❯ message #design │
╰──────────────────────────────────────────────────────────────────────────╯
INSERT ada@work ● 2 elsewhere esc normal · ? help
The title bar says what you are looking at and whether it is reaching anyone; the statusline says who and where you are. Neither repeats the other. Each speaker's block carries a rule down its left edge in that person's own colour, so you can tell whose paragraph you are three lines into without going back to the name; a line that says your name gets a solid bar instead. Speakers are laid against that rule rather than against the clock, so the names and the rule each have a straight edge and the eye has two lines to read between rather than a ragged column.
Nothing openchat says for itself goes into the chat. A command's answer, an error, an acknowledgement — "copied the invite to the clipboard" — is written into the prompt's own border for a few seconds and then taken down. The transcript is what happened in the conversation; a reply to something you pressed is not that, and leaving one there forever, three deep if you pressed the key three times, is how a log stops being worth reading. Anything bigger than a line opens a window instead: a list of members, of accounts, of commands, or anything key-shaped.
The conversation list hides itself below 64 columns, and space u e turns it off at
any width.
Nothing you open moves anything, and it mostly covers nothing either. Every window is laid over the frame rather than wedged into it, so the title bar, the conversation, the prompt and the statusline are in the same place whatever is on screen.
The key menu stands in the bottom-right corner, on top of the prompt — where which-key stands in LazyVim, and where your eye already is when you are halfway through a chord. That corner is also the expensive one, because the transcript is anchored to the bottom and those are the newest messages, so the menu does not take their rows. It stands beside them: every line it reaches keeps the columns to the left of the frame, cut with an ellipsis only where it would actually run underneath. For the same reason it grows taller before it grows wider — a row it covers keeps its conversation, a column it takes does not:
ROOMS ─────────── │ ╭─ keys ───────────────────╮
▌ #design ★ │ │ , switch conversation │
#ops │ │ ? help │
DIRECT ────────── │ │ ␣ find conversation │
@grace 2 │ │ a accounts │
│ │ e conversation list │
│ │ k your keys │
│ │ s settings │
│ 20:06 ● bob │ got the in…│ y your public key │
│ │ DHT, no se…│ f +find │
│ │ │ q +quit │
│ 20:06 › you │ that is th…│ r +room │
│ │ when they …╰─ esc cancel ─────────────╯
╭─ #design ────────────────────────────────────────────────────────────────────╮
│ › i to write · ␣ for the key menu · : for a command │
╰──────────────────────────────────────────────────────────────────────────────╯
The key is the only thing on a row in the accent colour, because it is the only
thing you are looking for; + marks the ones that open another menu rather than
doing something, at the left edge of the label where they line up with each other.
: opens the same kind of window at the top of the chat pane, where vim's own
command line lives and where the transcript is not using the rows — a line to type
into, with the commands that match it underneath:
╭─ ▸ command ──────────────────────────────────────────────────────── 1/29 ─╮
│ :inv │
├───────────────────────────────────────────────────────────────────────────┤
│ ❯ :invite show this room's invite, in a window you can copy from │
╰─ ⏎ run · ⇥ complete · ↑↓ move · esc cancel ───────────────────────────────╯
A floating window — a finder, settings, a key — is different: it is composited over the middle of the frame, with the conversation still visible above and below it.
Your messages live in Hypercore logs — that is the database, and it is what lets
openchat work with no server. Around them are two SQLite files (Node's built-in
node:sqlite, no extra dependency), and the difference between them matters.
openchat.db, one per machine, is authoritative. It is the list of accounts
you have, which one you are in, and for each of them the rooms it has joined, the
people it has names for and its settings. Nothing in it can be recomputed from
anywhere — the room keys in it are how you get back into a room — so it is written
with a full fsync and a schema it does not recognise is an error rather than an
excuse to start over. An account is a row in it: a directory that merely exists is
not an account, which is what stops a machine collecting profiles nobody meant to
make.
index.db, one per account, is derived and disposable: delete it and it
rebuilds on the next run, and nothing in it is ever sent to a peer. It exists for
the three things a log is bad at — searching everything you have ever been told,
remembering how far you had read, and doing both without walking the log.
~/.openchat/
openchat.db accounts, rooms, contacts, settings, the account in use
profiles/<account>/
identity.json your keypair — the only irreplaceable file here
store/ Hypercore logs: the source of truth
index.db SQLite index: search and unread, rebuildable
An invite is the whole credential: paste it into :join and you are in. There is no
approval step and nobody to ask — any member who is already online verifies your
signed join block and admits you, usually in a second or two.
The room opens immediately and you can look around it straight away. Being admitted
is the part that needs somebody else to be online, so it happens in the background
and the statusline says waiting to be admitted until it lands. Nothing is blocked
while you wait, and there is no deadline: if nobody is around right now, you are
admitted whenever one of them next appears.
:leave announces that you are going and removes the room from this machine.
:delete removes it silently, along with its local history and search index.
Neither reaches anyone else's copy, and openchat does not pretend otherwise: there is no server to delete a room from. Every member holds the log, so the people still in it keep the room and keep what you wrote. Leaving is leaving, not erasure.
If you own a room and want it shut to newcomers, :close it before you go —
once you leave, nobody can admit anyone.
An account can only be open in one place at a time — its log has a single writer,
so its storage takes an exclusive lock. Running openchat in two terminals is
therefore two accounts, not two windows onto one:
openchat work # one terminal
openchat personal # anotherThey share nothing: separate keypairs, separate storage, separate index. To
message one from the other, run :whoami in one and :dm <that key> in the
other — and only in one of them. The person you write to does not have to do
anything for your message to arrive.
One fuzzy picker behind every space f binding: conversations, rooms, people, room
members, commands, keymaps, accounts, and the messages in front of you.
The matched characters light up, so you can see why something matched. Scoring
follows fzf's shape rather than a substring test — dsg puts #design above
#wds-logging, because a match that starts on a word boundary and runs almost
consecutively is the one you meant.
Public keys are matched as a prefix, not fuzzily. Every short query is a subsequence of 64 hex characters, so fuzzy-matching keys would make every picker return every row; pasting the front of a key still finds it.
Space is the leader, and this is the whole of it — every binding openchat has.
? opens the same list in a window, and space f k searches it.
Modes
i, a |
write a message (INSERT) |
esc |
back to NORMAL |
space |
the key menu: everything you can press from here |
: |
the command line |
? |
the whole keymap, in a window |
Finding
space space, space f f |
find a room or conversation |
space , |
switch conversation |
space f d |
find a person to message |
space f r |
find a room |
space f m |
find a member of this room |
space f c |
commands |
space f k |
keymaps |
space f a |
accounts |
space f s, / |
search everything anyone has said |
Rooms and people
space r n |
new room |
space r j |
join with an invite |
space r i |
invite someone to this room |
space r m |
list the members |
space k |
your keys, and the phrase behind them |
space y |
your public key |
Moving around
shift+tab, ]b / [b, L / H |
next / previous conversation |
gd |
newest unread |
space e, ctrl-e |
the conversation list: j/k move, ⏎ opens, esc returns |
ctrl-u / ctrl-d, page up / page down |
read back through the conversation |
gg / G |
jump to the start / to the newest |
Windows and toggles
space s |
settings |
space a |
accounts |
space ? |
help |
space u t / space u c |
toggle timestamps / compact lines |
space u m |
toggle mouse capture |
space u e |
toggle the conversation list |
space q q, ZZ |
quit |
Ctrl+P Ctrl+K Ctrl+G Ctrl+O |
finder, command line, settings, accounts — from either mode |
The last row is the point of the ctrl chords: insert mode is the default, so anything worth reaching mid-sentence has one and you never have to leave the message you are writing to switch room.
While typing, the bindings are readline's: Ctrl+A/Ctrl+E for the ends of the
line, Ctrl+W to delete a word, Ctrl+U to clear it, and ↑/↓ through what you have
already sent. The command line has the same bindings, its own history on Ctrl+P /
Ctrl+N, and filters fuzzily as you type — :dl finds :download, Tab completes
the highlighted one and leaves the cursor where its argument goes. Type something
that is not a command name and it searches what commands do instead, so :palette
finds :theme.
space s, or :settings. Stored per account, written as you change them, and
applied immediately.
| Appearance | Theme, timestamp format, compact lines, Nerd Font icons, startup banner |
| Keys | Start in normal mode, mouse behaviour, which-key delay |
| Privacy | Auto-download limit, full or abbreviated public keys, presence, bell |
Eight palettes ship, all of them real terminal themes rather than inventions: Tokyo
Night (storm and night), Catppuccin Mocha, Catppuccin Latte for a light terminal,
Gruvbox Dark, Rosé Pine, Nord, and a monochrome one that leaves your terminal's own
colours alone. :theme <name> switches without opening anything.
Showing public keys abbreviated is the setting worth knowing about: it is there for when you are sharing a screen.
Clicks, wheel scrolling and click-outside-to-dismiss work inside every floating window. A float knows its own position on screen to the row, which is what makes a click land on the line you saw under the pointer.
Outside them, mouse reporting is off by default, on purpose. While a terminal is reporting the mouse it stops letting you select text, and selecting a message to copy it is worth more than clicking one. So reporting is switched on when a float opens and handed straight back when it closes.
mouse: always in settings takes the other trade: the wheel scrolls the transcript
and clicking a room in the conversation list opens it.
The reports are filtered out of stdin before Ink ever sees them, so a click can never end up typed into your message.
An account is a username and a keypair. There is no directory to register in and nobody to register with: the username is a label on a directory of your own keys, and the key is who you actually are on the wire.
Each account has its own storage, rooms, contacts and settings, and nothing is shared between two of them — which is the entire point of having a second one.
╭─◆ Accounts ─────────────────────────────────────────────── 2 ─╮
│ each one is its own keypair — nothing is shared between them │
├───────────────────────────────────────────────────────────────┤
│ ❯ ● work ada 03d35f4c5d0f… in use │
│ · alias nobody 9f21ab77c410… 2 rooms │
│ ⏎ switch · n new · esc close │
╰───────────────────────────────────────────────────────────────╯
space a or :accounts opens it.
- ⏎ switches. The swarm and every core close, and a different keypair comes up in their place. It is a real teardown, not a change of label.
- n makes a new one — a username, a display name, and a fresh key. It shows you the recovery phrase and will not move on until you say you have written it down.
There is no way in. openchat generates a keypair on the machine that uses it and never reads one back, so an account here has never existed anywhere else and cannot be carried onto a second machine.
You can also run two at once, one per terminal:
openchat work # terminal one
openchat personal # terminal twoA name this machine has not seen is a new account: openchat walks you through
generating its key rather than complaining that it does not exist. openchat --list
shows what is already here, and --profile <name> is the same thing as the bare
argument for anyone who has it in a script. OPENCHAT_PROFILE does that job from the
environment, so a terminal can be pinned to an account by exporting it once.
:whoami in one gives you its public key; :dm <key> in the other opens a
conversation.
There is no global user directory, because there is no server to hold one. You reach someone because you have their public key, and there are three ways to get one:
- A room you share.
space f mor:memberslists everyone's key, and you can DM any of them. In practice this is how most conversations start. - A contact you saved.
:add <key> <name>, then:dm <name>from then on. - A key they gave you.
:whoamiprints yours; hand it over however you like.
Nobody can enumerate users, and nobody can cold-message you without your key.
A DM needs no invite at all. Both identities are Ed25519 keys, which convert to X25519, so each side runs Diffie-Hellman against the other's public key and independently derives the same secret. Nothing is transmitted and nothing is negotiated:
:dm 03d35f4c5d0f36a0… # or a contact name, once you have saved one
From that shared secret openchat derives the discovery topic and the encryption key. Deriving the topic from a secret is the interesting part: a third party watching the DHT cannot compute it, so they cannot tell the conversation exists, let alone read it. Contrast a room, whose topic is public by design.
Structurally a DM is not an Autobase. With two participants there is nothing to
linearize across an unknown writer set — each side appends to its own outbox core,
both read both, and protocol/order.js merges them. Same ordering rule as rooms, so
both people always see the same conversation.
The person who opens a room owns it. Ownership is established by the log itself: the creator's own membership record is the first one written, so it needs no separate ceremony, and every member independently agrees who the owner is.
:close stop new members joining (existing ones keep talking)
:reopen let the invite work again
:transfer <who> hand the room over — you lose control immediately
:remove <who> remove a member; their history stays
:allow <who> undo a removal
Removal is recorded in the room's log, not just acted on once. That matters because a removed member still holds the invite: without the record they would simply ask to join again and any honest client would relay them back in.
Only the owner can do these, and that is checked by every member's client when applying the block, not just by the owner's.
One honest caveat:
:closeis enforced by members' clients refusing to relay a newcomer's join. A member running a modified client could still let someone in, exactly as they could hand out the invite again. It is not a cryptographic seal.:removeis enforced by the log.
:file <path> sends one. Metadata goes into the log; the bytes live in a Hyperblobs
core and are fetched on demand, with a real progress bar driven by the transfer
rather than a timer. Files under the auto-download limit (5 MB by default, a setting)
arrive by themselves; larger ones wait for :download <id>.
The sender's sha256 is verified after every fetch, and incoming filenames are sanitised before they touch your download directory.
openchat open the account you used last
openchat <account> open that account, and create it if it is new
openchat --list the accounts on this machine
openchat --profile <name> the same as `openchat <account>`
openchat --help this message
openchat --version print the version
That is the entire shell surface — it picks an account and nothing else. Everything
else is a command inside the app, typed after :.
A bare word that is also the name of a command is taken as somebody reaching for the
command, not as an account: openchat whoami points you at :whoami rather than
quietly making an account called whoami. An account that already exists always wins,
so if you do have one called that, it opens.
|
Talking People |
Interface Accounts Owner only |
Several rooms and DMs stay open at once. Shift+Tab moves between them without
leaving the message you are writing, space f f finds one by name, and the
statusline shows how many messages are waiting elsewhere.
The discovery topic and the encryption key are separate, and only one of them is public. This split is the whole design, so it is worth being precise about:
- The topic is
sha256("openchat:topic:v1" || roomKey). It is announced to the DHT, which means anyone can look it up, find the swarm, and open a connection to your room. That is expected and fine. - The encryption key is 32 random bytes generated when the room is created. It encrypts every Hypercore in the room and every message envelope. It is never announced, never derived from anything public, and never sent over the network.
A stranger who discovers your room on the DHT can connect and will read nothing.
There is a test for exactly this — test/integration/room.test.js, "a peer without
the encryption key cannot read the room".
An invite carries both. That is what makes it work, and what makes it dangerous: an invite string is not a link, it is the key to the room. Treat it like a password. Anyone who sees it — in a screenshot, a pasted log, a group chat you forgot was public — is a member.
Identity is an Ed25519 keypair derived from a 32-byte seed stored at
~/.openchat/profiles/<name>/identity.json with 0600 permissions. Only the seed is
saved; the keypair is re-derived on load, which is why :backup can hand you the seed
as a BIP39 phrase. That phrase is your identity — anyone with it can post as you.
openchat itself never reads one back: there is no restore, so identity.json is the
only copy of an account this program will ever make or accept.
Every message is signed and encrypted. The envelope
(src/protocol/envelope.js) is:
magic(3) | version(1) | author(32) | iv(12) | len(4) | ciphertext(len) | signature(64)
AES-256-GCM for the body, Ed25519 over the header and ciphertext. Signing the ciphertext rather than the plaintext lets a receiver throw out a forged frame before spending anything decrypting it. On the way in, the signature is checked and the decrypted message's author field is checked against the key that signed it, so a member cannot post under someone else's name.
The author's public key is deliberately in the clear. Room membership is already known to everyone holding the invite, and it is what makes verify-before-decrypt possible — but it does mean someone observing the transport sees which keys are talking, without learning what they said.
These are deliberate, and stated plainly because a security tool that hides its edges is worse than one that has them.
| Anyone with the invite becomes a writer | There is no admin approval on join. A leaked invite is a leaked room — :remove keeps someone out afterwards, but it does not un-leak the invite. Admin-gated membership is the next security milestone. |
| Someone must be online to admit a newcomer | Joining needs an existing writer to receive the request and append it. If every member is offline, a new member waits. |
| No forward secrecy | One long-lived key encrypts everything in a room or a DM. Someone who obtains it can read that conversation's whole history. |
:close is client-enforced |
See Rooms you own. :remove is enforced by the log; :close is not. |
| A DM partner can write freely into their own outbox | That is the point, but it means the only spam control in a DM is not giving out your key. |
Room._refresh() re-reads the whole range |
Fine for a few thousand messages, and the first thing to make incremental if a room outgrows it. |
| Both people must be online at once | There is no store-and-forward, because there is nowhere for a message to wait. A message written while the other side is offline sits in the sender's outbox and lands the next time the two of you overlap. |
| A lost machine is a lost account | openchat generates a keypair where it is used and never reads one back — there is no restore. The recovery phrase is the seed written down, but nothing in this program will turn it back into an identity, so identity.json is the only copy of an account that exists. |
| Public DHT connectivity is not exercised in CI | Tests run against a local DHT testnet. Real internet discovery and NAT traversal should be verified on real machines before trusting it. |
Source is split into two tiers, and the split is enforced by a test rather than by convention.
| Tier | Location | Contents |
|---|---|---|
| Shared | src/protocol/src/ui/model/ |
Message schema and codecs, the encrypted envelope, causal ordering, invite encode/decode, command parsing, the chat state reducer — and the interface's own logic: the fuzzy matcher, the keymap and its chord resolver, the message buffer, the settings schema, the float geometry. No Node built-ins, no native modules. |
| Runtime | src/core/src/ui/ink/ |
Corestore, Hypercore, Autobase, Hyperswarm, Hyperblobs, sodium-native, and the Ink components. |
Two tests hold this together:
test/unit/portability.test.jsfails if anything in the shared tier imports a Node built-in, a native module, or anything from the runtime tier. It is what keeps the wire format and the view-model readable, testable and reviewable without a DHT, a swarm or a terminal in the way.test/unit/vectors.test.jsruns both crypto backends — sodium +node:cryptoon one side, WebCrypto +@nobleon the other — and asserts they produce byte-identical frames and can each decrypt what the other sealed.
|
A room is an Autobase of per-member writer cores, linearized into a Hyperbee of
sealed envelopes ( |
Corestore replicates cores by key, so an existing member has no way to discover a
newcomer's core — a join request appended optimistically is invisible to everyone.
Instead a joiner broadcasts a signed JOIN block over a Protomux channel multiplexed
onto the same connection ( |
|
Lamport clock, then wall clock, then author key, then id
( |
A modal interface has to decide what a key means before anything acts on it, so
nothing but |
|
Terminals report a click as an absolute row and column, and no layout engine hands a
component its coordinates. |
|
npm install
npm run build # bundle to dist/
npm run dev # rebuild on change
npm run check # lint + the full test suite
npm run shot # screenshots of the real UI, into shots/npm run shot drives two real clients on a local DHT, renders the real Ink app
into a harness, and paints the ANSI frames it produces in a headless browser —
so a screenshot that looks wrong is the interface looking wrong, not a mockup
drifting from it. Pass a filter to take only some of them: npm run shot -- keys.
npm run demoA guided tour on one machine with no network: two real accounts, two real clients, the real Ink UI, and a local DHT. It opens a room, joins it from the second account, sends a DM derived from nothing but a public key, switches between conversations, closes the room and watches a valid invite get refused, transfers ownership, and sends a file. Every frame it prints came out of the actual app.
npm test165 tests, no network needed. Unit tests cover the codecs, invites, ordering
determinism under shuffled input, the crypto vectors, the portability lint, the fuzzy
matcher, the keymap and chord resolver, the message buffer, float and dock geometry,
and the mouse parser. Integration tests run two or three real clients against a local
hyperdht testnet and cover two-peer chat, join-by-invite, an offline member
replaying what it missed, the encryption boundary, attachment transfer, room
ownership, account switching, and the Ink UI rendered against live peers.
The tests pin the DHT to 127.0.0.1. Sandboxed containers often have an outward
interface with an address the host cannot reach itself on, and hyperdht will announce
it and then fail to connect; binding to loopback keeps the local testnet
self-consistent.
Built on the Holepunch stack — Hyperswarm · Hypercore · Autobase · Hyperblobs · Ink
N3XT Agency
n3xt-agency.com · contact@n3xt-agency.com
openchat is open source under the MIT License.
Encryption is only as good as the invites you hand out — read the security model before you rely on it.