omajot

For the nerds

Under the hood

omajot is written in Zig 0.17 and QML. This page shows the parts, most of them animated: the architecture, the CRDT, the edits that cross on a pipe, the hub, the limits, the one core that runs natively and in the browser, and the one binary.

Architecture

One hub stores and forwards changes. Every device keeps a full copy of all notes. The hub never merges anything: it only moves operations and attachments. The dots below are operations on their way.

Tailscale connects the devices. tailscale serve terminates HTTPS with a real certificate and adds the caller's identity as a header. The hub listens on loopback only and admits exactly one login. The plugin talks to the daemon over its standard input and output; omajot tui and the note commands use the daemon's unix socket. All of them speak the same JSON lines.

The CRDT

You can edit the same note on a laptop in a plane and on a phone at the same time. When both come online, omajot merges the edits. It uses a CRDT (conflict-free replicated data type), written from scratch in Zig. Step through it:

Laptopreplica L
Phonereplica P

✓ The same text on both devices

Operations are small JSON objects, for example {"v":1,"k":"ins","r":"…","c":42,"t":…,"n":"…","o":"…","s":"hello"}. The hub stores them without reading them.

Edits and patches

The editor and the engine run in different processes. You can type while a change from another device arrives. Then your edit and the remote patch cross on the pipe:

omajot solves this with two-party operational transformation, in the style of the Jupiter system:

The reference is src/core/ot.zig. The QML plugin and the web app port it line by line and test their ports. Undo works on your own edits only. The built-in undo of a text field would also undo changes from other devices, so omajot replaces it.

The hub

The hub is a log with a doorbell. Watch one change arrive: the laptop pushes a batch, the hub appends it and calls fsync, rings the doorbell, the phone pulls, and a resent batch gets its old number back.

Everything bounded

The hub runs on baz, a Zig web framework on the bounded/http engine (io_uring on Linux, kqueue on macOS). Both follow one rule: set explicit limits, and reserve memory and threads before the first request. When a limit is reached, the server refuses or waits. It does not grow.

Connections in use: 0 / 24 Waiting: 0 Memory: reserved at the start, the same with 0 or 24 busy
a slot in use: its two body buffers exist from the startrequests waiting for a free slot

A simulation with the real number of slots. Bursts fill every slot; then new requests wait. No slot and no buffer is ever added.

omajot sets its limits as named constants in the source. The build of this site reads them from the code:

LimitValueWhere
Connections24src/hub/hub.zig
Worker threads4src/hub/hub.zig
Waiting event streams64src/hub/hub.zig
Memory budget of the HTTP engine256 MiBsrc/hub/hub.zig
Request body (one batch or one blob chunk)1 MiBsrc/hub/store.zig, blobs.zig
Attachment16 MiB, in chunks of 1 MiBsrc/hub/blobs.zig
Request deadline30 ssrc/hub/hub.zig
Event stream route deadline600 ssrc/hub/hub.zig
Idle keep-alive wait60 ssrc/hub/hub.zig
Event stream ends before the deadline10 ssrc/hub/hub.zig
Heartbeat on idle event streams15 ssrc/hub/hub.zig
Web app files512 files, 128 MiB in totalsrc/hub/static.zig
Log loaded at start4 GiBsrc/hub/store.zig

Why request bodies are 1 MiB

bounded/http reserves the body buffers of all connections when it starts: two buffers of max_body per connection. With 24 connections:

1 MiB bodies (omajot)
48 MiB
16 MiB bodies
24 × 2 × 16 MiB = 768 MiB, reserved before the first request

So a batch has at most 1 MiB, and a large image goes up in 1 MiB chunks. The hub resumes an interrupted upload at the last good chunk.

Deadlines

In bounded/http, a deadline covers the whole request, also a long event stream. The hub uses three separate limits:

The per-route deadlines and the separate idle timeout came into baz and bounded/http during the work on omajot.

One core, native and WASM

The core in src/core/ holds the CRDT, the note model, the protocol engine, the HTML-to-markdown converter and the QR encoder. It is pure Zig: no std.Io, no clock, no randomness, no global state. The caller passes the allocator and the time. Bytes go in, bytes come out.

1engine, the same Zig code
170 KiBcore.wasm, ReleaseSmall
75 KiBgzip -9 of core.wasm
3places for one QR encoder
// the whole wasm interface (src/wasm/wasm.zig)
omj_alloc(len) → ptr            omj_free(ptr, len)
omj_engine_new(replica_lo, replica_hi) → handle
omj_call(handle, ptr, len, now_ms) → result     // one request, reply + events
omj_ingest(handle, ptr, len) → result           // operations from the hub
omj_take_new_ops(handle) → result               // operations to send
omj_pending(handle)      omj_engine_free(handle)      omj_free_result(result)

One static binary

omajot is one program. The hub, the daemon, the terminal UI and every note command are subcommands of the same file, and the web app is inside it.

omajot Linux: static musl

Servers

hubdaemon

For people

tuiqrstatus--version

Notes, for people and agents

lscatsearchnewwriteeditappendreplacemvrmmkdirrmdirtagshistoryrestoreexport

Inside

the Zig corethe web app (654 KB)libvaxis (TUI)baz · bounded/http
Release targetSize (stripped, ReleaseSafe)
x86_64-linux-musl3.53 MB
aarch64-linux-musl3.17 MB
aarch64-macos3.18 MB
x86_64-macos3.51 MB
x86_64-windows-gnu (experimental)3.98 MB

Measured on 2026-09-29 with Zig 0.16.0, cross-compiled on one x86_64 Linux computer.

Tests

The most important property is convergence: replicas that got the same operations, in any order, show the same notes. The core tests that with random operations, delivered late, out of order and twice:

✓ 4 replicas, 1 text

$ zig build test
$ npm test
$ cd web && npm test && npm run e2e
$ python3 tools/tui_e2e.py