matrix-gleam-sdk
v0.1.0
Published
A lean Matrix client SDK with from-scratch E2EE, written in Gleam and usable from TypeScript/JavaScript.
Maintainers
Readme
matrix-gleam-sdk
A lean Matrix client SDK for Gleam,
targeting the JavaScript backend (Node, Deno, Bun, browsers — anywhere fetch exists).
It wraps the everyday parts of the
Client-Server API: logging in,
syncing, reading history, sending messages, managing rooms, and end-to-end
encryption (Olm + Megolm) — with zero runtime dependencies beyond the Gleam basics
(gleam_stdlib, gleam_json for encoding, gleam_javascript for promises).
HTTP is a 20-line fetch FFI shim and the crypto is a thin shim over the runtime's
built-in node:crypto; there is nothing else underneath. No libolm, no vodozemac, no WASM.
Design
- A
Clientis just an immutable record — homeserver URL, access token, user ID, device ID. No hidden state, no event emitters, no caches. Copy it, store it, serialise it. - Everything returns
Promise(Result(a, matrix/error.Error)). One error type for the whole SDK:Network,Server(status, code, message),RateLimited,Unexpected. - Events keep their content as
Dynamic. Unknown event types flow through untouched; typed helpers (event.message,event.text) and ordinarygleam/dynamic/decodedecoders read out what you need. - State aggregation is a pure fold.
matrix/roomturns lists of state events intoRoomsnapshots with no I/O, so it is trivially testable and reusable. - E2EE from scratch, not from libolm. The Olm double ratchet and Megolm group
ratchet are implemented directly from the spec (see below).
The only crypto primitives used are the platform's own — X25519, Ed25519, HKDF,
HMAC, AES-CBC, all from
node:crypto. The ratchets and the entire account/session state are pure, immutable Gleam values.
For how this surface lines up with a real client's needs, see
COVERAGE.md — a map of the matrix-js-sdk API that
Cinny exercises onto this SDK.
Modules
| Module | Contents |
|---------------------------|----------|
| matrix | The facade: sessions, rooms, sending, reactions/replies/edits, history, relations & context, search, account data, profiles, media, spaces, devices, push |
| matrix/sync | /sync long-polling: once and the auto-retrying stream loop |
| matrix/room | Pure folding of state events into Room snapshots |
| matrix/event | The Event type, its decoder, and content helpers |
| matrix/api | Client, raw request/expect — call any endpoint the facade doesn't wrap |
| matrix/error | The single Error type |
| matrix/crypto | The E2EE engine: the Vault holding account + sessions, and all Matrix crypto JSON |
| matrix/e2ee | HTTP wiring for E2EE: keys, to-device, share-key, send-encrypted, cross-signing, key backup |
| matrix/crypto/olm | The Olm double ratchet (m.olm.v1.curve25519-aes-sha2) |
| matrix/crypto/megolm | The Megolm group ratchet (m.megolm.v1.aes-sha2) |
| matrix/crypto/cross_signing | The master / self-signing / user-signing key hierarchy |
| matrix/crypto/verification | SAS device verification (emoji/decimal/MAC) |
| matrix/crypto/secret_storage| Secret storage (SSSS): AES-CTR+HMAC, PBKDF2, recovery keys |
| matrix/crypto/key_backup | Server-side key backup (m.megolm_backup.v1) |
| matrix/crypto/primitives| Crypto bindings + unpadded base64 + canonical JSON |
Using it from TypeScript / JavaScript
The SDK ships a prebuilt, dependency-free ESM bundle with TypeScript types in
dist/, so it can be consumed from any TS/JS project without a Gleam
toolchain. The public surface is an idiomatic MatrixClient class —
async methods that resolve on success and reject with a typed MatrixError
(no Gleam Result/Option leaks; mxc media is Uint8Array).
import { MatrixClient, MatrixError } from "matrix-gleam-sdk";
const client = await MatrixClient.login("https://matrix.org", "alice", "pw");
const room = await client.createRoom({ name: "Hi", preset: "private" });
const eventId = await client.sendText(room, "Hello from TypeScript!");
await client.sendReaction(room, eventId, "👋");
const page = await client.messages(room, { limit: 20 });
const batch = await client.sync({ timeoutMs: 0 }); // typed SyncResult
try {
await client.login(/* ... */);
} catch (e) {
if (e instanceof MatrixError) console.error(e.kind, e.status, e.message);
}MatrixClient covers the everyday surface: sessions, sending (text / message /
event / state / reactions / replies / edits / redactions), rooms (create / join /
leave / invite / kick / ban / knock / name / topic / avatar), reading (sync,
history, state, context, relations, search), account data, profiles, media
(upload / download / file / image), devices, and a request() escape hatch.
End-to-end encryption is exposed under the raw namespace (the Gleam-shaped
API) — see ts/example.ts and the E2EE section below.
npm run build # gleam build -> esbuild bundle -> tsc declarations -> dist/The bundle inlines the compiled Gleam; the only external is the built-in
node:crypto, so it runs on Node / Bun / Deno. (See the runtime note under
End-to-end encryption about browsers.)
An echo bot
import gleam/dict
import gleam/javascript/promise
import gleam/list
import gleam/option.{None}
import matrix
import matrix/event
import matrix/sync
pub fn main() {
use client <- promise.try_await(matrix.login(
homeserver: "https://matrix.org",
username: "echobot",
password: "secret",
))
// Skip everything that happened before we logged in.
use first <- promise.try_await(sync.once(client, since: None, timeout_ms: 0))
sync.stream(client, since: option.Some(first.next_batch), handler: fn(batch) {
dict.each(batch.joined, fn(room_id, room) {
list.each(room.timeline.events, fn(ev) {
case event.text(ev), ev.sender == option.Some(client.user_id) {
Ok(body), False -> {
matrix.send_text(client, room_id, "you said: " <> body)
Nil
}
_, _ -> Nil
}
})
})
sync.Continue
})
}The stream loop retries network failures with exponential backoff and waits out
rate limits on its own; it only stops when the handler returns sync.Stop or the
server rejects us outright (e.g. the token was revoked).
Building a room snapshot
use events <- promise.try_await(matrix.state(client, room_id))
let snapshot = room.new(room_id) |> room.apply_all(events)
// snapshot.name, snapshot.topic, room.joined_members(snapshot), ...During a sync, apply each batch's state then timeline events to keep the
snapshot current — room state in Matrix is just a left fold over events.
Calling unwrapped endpoints
The facade covers the common 90%. For anything else, matrix/api speaks raw:
api.expect(
client,
api.Get,
"/_matrix/client/v3/rooms/" <> api.encode(room_id) <> "/joined_members",
[],
None,
decode.at(["joined"], decode.dict(decode.string, decode.dynamic)),
)End-to-end encryption
E2EE lives behind a single stateful object, the crypto.Vault, which holds a
device's Olm account and all of its Olm and Megolm sessions. Everything the Vault
produces and consumes is plain JSON, so it stays decoupled from the network;
matrix/e2ee wires it to the homeserver.
The two algorithms are implemented directly from the Olm and Megolm specifications:
- Olm (
m.olm.v1.curve25519-aes-sha2) — the Signal double ratchet over Curve25519, used for the secure device-to-device channel that distributes room keys. Triple-DH handshake, root/chain key ratchets, pre-key and normal message wire formats, skipped-key handling for out-of-order delivery. - Megolm (
m.megolm.v1.aes-sha2) — the four-part hash ratchet for efficient group messaging, with Ed25519-signed messages and the session sharing/export formats.
Both are byte-compatible with libolm, the reference C implementation. The
interop/ directory contains a cross-implementation test that encrypts with
libolm and decrypts with this SDK (and vice versa) for both ratchets:
npm install @matrix-org/olm
node interop/interop.mjs # 16 checks vs libolm: Olm, Megolm, SAS, key backupThe flow
import matrix/crypto
import matrix/e2ee
// 1. Create a device vault and publish its keys.
let vault = crypto.new(user_id: client.user_id, device_id: client.device_id)
use vault <- promise.try_await(e2ee.publish_keys(client, vault, one_time_key_count: 50))
// 2. Discover the recipients' devices and share a room key with them.
use devices <- promise.try_await(e2ee.query_keys(client, ["@bob:example.org"]))
use #(vault, _session_id) <- promise.try_await(
e2ee.share_room_key(client, vault, room_id, devices),
)
// 3. Send an encrypted message — Megolm under the hood.
use #(vault, _event_id) <- promise.try_await(e2ee.send_encrypted(
client, vault, room_id, "m.room.message",
primitives.JsonObject([
#("msgtype", primitives.JsonString("m.text")),
#("body", primitives.JsonString("encrypted hello")),
]),
))Incoming events are handled with the pure Vault functions: crypto.decrypt_olm
for to-device messages (auto-establishing inbound sessions from pre-key messages),
crypto.add_inbound_session to store a received m.room_key, and
crypto.decrypt_room_event for m.room.encrypted timeline events (with replay
rejection built in). Persist the whole Vault between runs with
crypto.to_json_string / crypto.from_json_string.
Scope
Implemented: device identity & one-time keys (signed), Olm 1:1 sessions, Megolm
room sessions, key sharing over to-device, room message encrypt/decrypt, replay
protection, and full session persistence — plus the trust infrastructure:
cross-signing (crypto/cross_signing), SAS device verification
(crypto/verification), secret storage / SSSS (crypto/secret_storage), and
server-side key backup (crypto/key_backup). The SAS derivation and the key-backup
encryption are byte-checked against libolm.
What's left to the application: driving the m.key.verification.* to-device state
machine (the module supplies the crypto for each step), QR-code verification, and
key requests for missing sessions. See COVERAGE.md for the full map.
Runtime note. E2EE uses the synchronous primitives in
node:crypto, so it runs on Node, Bun, and Deno. The non-encrypted client (matrix,matrix/sync, …) only needsfetchand runs anywhere, browsers included.
Examples
Runnable examples live in src/examples/. They read secrets from a git-ignored
.env file — copy .env.example to .env and fill it in.
gleam run -m examples/register # register one account via a registration token
gleam run -m examples/showcase # profile, room management, attachments, history
gleam run -m examples/group_chat # the full E2EE story (below)
gleam run -m examples/trust # search, cross-signing, key backup, SSSS, SASexamples/trust exercises the newer features live against a homeserver:
full-text search of a plaintext room; cross-signing bootstrap (over UIA)
with a self-signature and chain verification; a complete key-backup
round-trip (create a version, back up a Megolm session, fetch it back, restore
it, and decrypt a message); a secret-storage round-trip through account
data; and a locally-derived SAS emoji/decimal code. Its actual output:
2. Cross-signing ------------------------
✓ uploaded master/self-signing/user-signing keys
✓ self-signed this device
✓ trust chain verifies (master -> SSK): yes
3. Key backup ---------------------------
✓ created backup version 3077
✓ uploaded the session key to the backup
✓ restored the session and decrypted: "a backed-up message"examples/showcase tours the everyday (non-encryption) surface against a real
homeserver: it uploads an avatar and sets the profile, creates a room and sets its
name/topic/avatar, invites and kicks a user, sends a file (m.file) and an image
(m.image), downloads the image back and verifies the bytes, then sends several
messages and paginates the room history.
examples/group_chat is an end-to-end demo against a real homeserver:
- Creates (or logs in to) three accounts — Alice, Bob, Carol.
- Alice creates a private room and invites Bob and Carol, who join.
- Alice sends a plaintext message; Bob reads it from his timeline.
- All three publish device keys; Alice enables encryption (
m.room.encryption). - Alice discovers the recipients' devices, establishes Olm sessions, shares a Megolm room key over to-device, and sends an end-to-end encrypted message.
- Bob and Carol sync, decrypt the room key (Olm) then the message (Megolm), and print the plaintext.
Its actual output:
3. Alice sends a PLAINTEXT message…
Bob reads: "Hello room — this message is NOT encrypted."
...
6. Bob and Carol sync, decrypt the key (Olm) and the message (Megolm)…
Bob decrypted: "Hello room — this message IS end-to-end encrypted!"
Carol decrypted: "Hello room — this message IS end-to-end encrypted!"Development
gleam test # pure unit tests: ratchets, crypto, decoders, room folding
gleam run -m smoke # live connectivity check against matrix.org
node interop/interop.mjs # cross-check Olm, Megolm, SAS, key backup against libolmThe suite is ~100 tests across thirteen files:
- Primitives — known-answer tests against the published RFC/NIST vectors (SHA-256, HMAC RFC 4231, HKDF RFC 5869, PBKDF2-SHA-512), plus base64/base58 known values, AES-CTR self-inversion, and canonical-JSON edge cases.
- Wire format — varint and tagged-field encoding, including multi-byte varints and truncation handling.
- Events & rooms — decoding across message/state/ephemeral/media shapes, and
folding state into a
Room(last-write-wins, membership transitions). - Ratchets — Olm (handshake, bidirectional, out-of-order) and Megolm (including a reseed past the 2^8 boundary).
- Persistence — Olm account/session and Megolm/Vault save-and-reload that still encrypt and decrypt after the round-trip.
- Engine — the type-1 Olm path, Megolm replay across many messages, and error handling for unknown sessions / corrupt state.
- Trust — cross-signing chain verification (incl. verifying another user), SAS symmetry/commitment/MAC, SSSS and recovery-key round-trips, the SSSS key check, and key-backup of a real Megolm session.
The interop script additionally checks the SAS codes/MAC and the key-backup encryption byte-for-byte against libolm.
