Read and verify freeq conversations: channels, history, search, signatures, pins.
IRC server and client with AT Protocol (Bluesky) identity authentication, end-to-end encrypted channels, iroh QUIC transport, peer-to-peer DMs, and federated server-to-server clustering.
Users authenticate with their Bluesky identity via a custom SASL mechanism
(ATPROTO-CHALLENGE). Standard IRC clients connect as guests. Authenticated
users get their DID bound to their connection — visible via WHOIS, enforced
for nick ownership, and usable for DID-based bans, invites, and persistent ops.
Try it now: irc.freeq.at
freeq is meant to be used by software as well as people, so every surface an agent looks for exists and cross-links the others:
spec/openapi.yaml; a drift test fails the
build if the router and the spec disagree).@freeq/mcp) gives any MCP client tools
to read, search, verify and take part in conversations. Not on npm yet: build
it (cd freeq-mcp && npm install && npm run build) and point your client at
node <repo>/freeq-mcp/dist/index.js.skills/: freeq (talking to
other people's agents), freeq-api (REST),
freeq-bots (building one).Attribution is the point: every message carries a ULID msgid and a signature,
and GET /api/v1/verify/{msgid} distinguishes a message signed by its author's
key from one merely relayed by the server.
The web client at irc.freeq.at provides:
https://irc.freeq.at/join/#channel| Channel | Policy | What it demonstrates |
|---|---|---|
#demo-follow | Must follow @chadfowler.com on Bluesky | Social graph verification (zero OAuth) |
#demo-github | Open join, freeq-irc/freeq collaborators get auto-op | Layered credentials + role escalation |
#demo-moderation | Open join, moderators appointed via credentials | Credential-based moderation pipeline |
freeq-server/ IRC server with SASL, WebSocket, iroh, S2S federation
freeq-app/ React web client (Vite + Tailwind)
freeq-auth-broker/ AT Protocol OAuth broker (persistent sessions)
freeq-sdk/ Reusable client SDK (connect, auth, events, E2EE, P2P)
freeq-tui/ Terminal UI client built on the SDK
freeq-site/ Marketing site (freeq.at)
The SDK exposes a (ClientHandle, Receiver<Event>) pattern — any UI or bot
can consume events and send commands.
┌──────────────────────────────────────────┐
│ IRC Wire Protocol │
├──────────┬──────────┬──────────┬─────────┤
│ TCP │ TLS │WebSocket │ iroh │
│ :6667 │ :6697 │ :8080 │ QUIC │
└──────────┴──────────┴──────────┴─────────┘
All transports feed into the same handle_generic() handler — the IRC
protocol is transport-agnostic. Each transport is zero-cost when not enabled.
cargo build --release
# Minimal: plain TCP only, in-memory
cargo run --release --bin freeq-server
# With persistence
cargo run --release --bin freeq-server -- --db-path data/irc.db
# With TLS
cargo run --release --bin freeq-server -- \
--tls-cert certs/cert.pem --tls-key certs/key.pem
# With WebSocket + REST API
cargo run --release --bin freeq-server -- --web-addr 0.0.0.0:8080
# With iroh transport (QUIC, NAT-traversing)
cargo run --release --bin freeq-server -- --iroh
# Full production setup
cargo run --release --bin freeq-server -- \
--listen-addr 0.0.0.0:6667 \
--tls-listen-addr 0.0.0.0:6697 \
--tls-cert /etc/letsencrypt/live/example.com/fullchain.pem \
--tls-key /etc/letsencrypt/live/example.com/privkey.pem \
--db-path ./irc.db \
--web-addr 0.0.0.0:8080 \
--iroh
Generate a self-signed cert for local development:
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout certs/key.pem -out certs/cert.pem -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
# Guest (no auth)
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick
# Bluesky OAuth (opens browser)
cargo run --release --bin freeq-tui -- 127.0.0.1:6697 mynick \
--handle alice.bsky.social
# App password fallback
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick \
--handle alice.bsky.social --app-password xxxx-xxxx-xxxx-xxxx
# Auto-join channels
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick \
-c '#general,#random'
# Explicit iroh transport
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick \
--iroh-addr <endpoint-id>
# Vi keybindings
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick --vi
Iroh auto-discovery: When connecting to a server that has --iroh
enabled, the TUI probes CAP LS for the iroh=<endpoint-id> capability
and auto-upgrades to iroh QUIC transport. No manual endpoint ID needed.
OAuth sessions are cached to ~/.config/freeq-tui/<handle>.session.json
so you don't need to re-authenticate on every launch.
Any IRC client works as a guest — irssi, WeeChat, HexChat, LimeChat, etc.
Connect to 127.0.0.1:6667 (plain) or 127.0.0.1:6697 (TLS). No special
configuration needed.
When --web-addr is set, the server accepts WebSocket connections at
ws://<addr>/irc. A test HTML client is included at freeq-server/test-client.html.
The server implements a custom SASL mechanism for AT Protocol identity:
CAP sasl, then AUTHENTICATE ATPROTO-CHALLENGEbase64url(json { session_id, nonce, timestamp })method: "crypto"): Signs challenge bytes with a
private key listed in the DID documentmethod: "pds-session"): Sends an app-password JWT;
server verifies against the PDSmethod: "pds-oauth"): Sends a DPoP-bound access token
with proof; server verifies against the PDS903 (success) or 904 (failure)CAP END, registration completesStandard IRC on port 6667 (plain) and 6697 (TLS). TLS auto-detected by port in the client. Always available.
Enabled with --web-addr. Accepts WebSocket IRC at /irc. Uses the same
IRC wire protocol — WebSocket is a transport, not a new protocol. Includes
a read-only REST API at /api/v1/ (channels, members, topics, messages).
Enabled with --iroh. Provides NAT-traversing encrypted QUIC connections
via iroh. The server generates a persistent secret
key (iroh-key.secret) on first run — endpoint ID is stable across restarts.
The server advertises its iroh endpoint ID in CAP LS:
CAP * LS :sasl message-tags iroh=44f1415c9db30989...
Clients auto-discover and upgrade to iroh when available.
Client-side channel encryption using AES-256-GCM with HKDF-SHA256 key derivation from a shared passphrase. The server relays ciphertext unchanged.
/encrypt <passphrase> Enable E2EE for current channel
/decrypt Disable E2EE for current channel
Wire format: ENC1:<nonce-b64>:<ciphertext-b64> — version-tagged, uses the
message body for robustness. All channel members must use the same passphrase.
Direct encrypted messaging between clients via iroh QUIC, bypassing the server entirely.
/p2p start Start your P2P endpoint
/p2p id Show your P2P endpoint ID
/p2p connect <id> Connect to a peer
/p2p msg <id> <message> Send a direct message
P2P conversations appear in dedicated p2p:<short-id> buffers. Wire format
is newline-delimited JSON (not IRC protocol). ALPN: freeq/p2p-dm/1.
P2P endpoint IDs are visible in WHOIS (numeric 672).
Servers cluster over iroh QUIC connections. Each server maintains its own local state and syncs channel membership, messages, topics, and DID-based ops across the federation.
# Server A: just enable iroh (accepts incoming S2S connections)
cargo run --release --bin freeq-server -- --iroh
# Server B: enable iroh + connect to Server A
cargo run --release --bin freeq-server -- --iroh \
--s2s-peers <server-a-endpoint-id>
Server A doesn't need --s2s-peers — it accepts incoming S2S connections
automatically when --iroh is enabled.
| Feature | Sync behavior |
|---|---|
| JOIN/PART/QUIT | Membership tracked per origin server |
| PRIVMSG | Channel messages relayed to all peers |
| TOPIC | Topic changes propagate |
| DID-based ops | Persistent ops sync via CRDT |
| Founder | First-write-wins CRDT resolution |
| NAMES | Includes both local and remote members |
| WHOIS | Shows DID, handle, and origin for remote users |
Channel authority (founder, DID-based ops) uses Automerge CRDTs for conflict-free convergence. Presence is NOT in the CRDT — it's S2S event-driven to avoid ghost users when servers crash.
tokio::sync::Mutex — no runtime thread blocking# Run against two live servers
LOCAL_SERVER=localhost:6667 REMOTE_SERVER=irc.freeq.at:6667 \
cargo test -p freeq-server --test s2s_acceptance -- --nocapture --test-threads=1
9 tests verify: connectivity, bidirectional message relay, NAMES sync, topic sync, PART/QUIT cleanup, and late-joiner state.
Full compatibility with RFC 1459/2812 basics:
/me)| Mode | Description |
|---|---|
+o nick | Channel operator |
+v nick | Voice |
+b mask | Ban (hostmask *!*@host or DID did:plc:xyz) |
+i | Invite-only |
+t | Topic lock (ops only) |
+k key | Channel key (password) |
MODE #chan +b did:plc:xyz): Bans by identity, not just
hostmask. Survives nick changes and reconnects.INVITE nick #chan): If the user is authenticated, the
invite is stored by DID and survives reconnect.GuestXXXX
at registration time.The server stores the last 100 messages per channel. When you join, recent history is replayed as standard PRIVMSG — works with any IRC client, no special protocol extension needed.
Rich media is supported through IRCv3 message tags, giving multipart/alternative semantics — the same content in two representations:
@content-type=image/jpeg;media-url=https://cdn.bsky.app/img/...;media-alt=Sunset;media-w=1200;media-h=800 :alice!a@host PRIVMSG #photos :Sunset https://cdn.bsky.app/img/...
| Client | What they see |
|---|---|
| irssi, WeeChat | Sunset https://cdn.bsky.app/img/... (clickable link) |
| freeq-tui | 🖼 [image/jpeg] Sunset 1200×800 https://cdn.bsky.app/img/... |
Media is hosted externally (AT Protocol PDS blob storage). The IRC server never handles media bytes — it just relays tagged messages.
Supported tag keys:
| Tag | Description |
|---|---|
content-type | MIME type (e.g. image/jpeg, video/mp4) |
media-url | URL where the media can be fetched |
media-alt | Alt text / description |
media-w | Width in pixels |
media-h | Height in pixels |
media-blurhash | Blurhash placeholder |
media-size | File size in bytes |
media-filename | Original filename |
Token bucket rate limiter (10 commands/second) kicks in after registration. The initial connection burst is not rate-limited, so clients that send many commands on connect (like LimeChat) work correctly.
The status bar shows:
Emacs mode (default):
| Key | Action |
|---|---|
| Ctrl-A / Home | Beginning of line |
| Ctrl-E / End | End of line |
| Ctrl-F / Right | Forward char |
| Ctrl-B / Left | Back char |
| Alt-F | Forward word |
| Alt-B | Back word |
| Ctrl-D | Delete char |
| Ctrl-H / Backspace | Delete back |
| Ctrl-K | Kill to end of line |
| Ctrl-U | Kill to beginning |
| Ctrl-W | Kill word back |
| Alt-D | Kill word forward |
| Ctrl-Y | Yank (paste kill ring) |
| Ctrl-T | Transpose chars |
| Alt-U | Uppercase word |
| Alt-L | Lowercase word |
| Alt-C | Capitalize word |
| Tab | Nick completion |
| Up / Down | Input history |
| Ctrl-N / Alt-N | Next buffer |
| Ctrl-P / Alt-P | Previous buffer |
| BackTab (Shift-Tab) | Previous buffer |
| PageUp / PageDown | Scroll messages |
| Ctrl-C / Ctrl-Q | Quit |
Vi mode (--vi):
Normal mode: h/l move, w/b/e word motion, 0/$ line edges,
i/a/I/A enter insert, x/X/D/C/S/s delete/change, p/P paste,
k/j history, dd clear line. Insert mode: standard typing, Esc to
exit to normal mode.
/join #channel Join a channel
/part [#channel] Leave current or named channel
/msg nick message Private message
/me action CTCP ACTION
/topic [text] View or set channel topic
/mode +o/-o nick Op/deop
/mode +v/-v nick Voice/devoice
/mode +b [mask] Ban (or list bans)
/mode +i/-i Invite-only
/mode +t/-t Topic lock
/mode +k/-k [key] Channel key
/op nick Shortcut for /mode +o
/deop nick Shortcut for /mode -o
/voice nick Shortcut for /mode +v
/kick nick [reason] Kick from channel
/ban mask Ban user
/unban mask Remove ban
/invite nick Invite to current channel
/whois nick Query user info
/names [#channel] List channel members
/raw <line> Send raw IRC line
/encrypt <passphrase> Enable E2EE for current channel
/decrypt Disable E2EE for current channel
/p2p start Start P2P endpoint
/p2p id Show your P2P endpoint ID
/p2p connect <id> Connect to a peer
/p2p msg <id> <text> Send P2P direct message
/net Show/hide network info popup
/debug Toggle raw IRC line display
/quit [message] Disconnect
/help Show commands
/net)Shows: transport type, server address, connection state, uptime, nick,
authenticated DID, iroh endpoint ID, E2EE channels, P2P DM status.
Close with Esc or q.
/debug)Toggles raw IRC line display in the status buffer (prefixed with ←).
Useful for diagnosing protocol issues.
When --web-addr is set, a read-only REST API is available:
| Endpoint | Description |
|---|---|
GET /api/v1/channels | List all channels |
GET /api/v1/channels/{name} | Channel info (topic, modes, member count) |
GET /api/v1/channels/{name}/members | Channel member list |
GET /api/v1/channels/{name}/topic | Channel topic |
GET /api/v1/channels/{name}/messages | Recent messages (with pagination) |
GET /api/v1/stats | Server stats |
All writes go through IRC — the REST API is strictly read-only.
freeq-server [OPTIONS]
Options:
--listen-addr <ADDR> Plain TCP address [default: 127.0.0.1:6667]
--tls-listen-addr <ADDR> TLS address [default: 127.0.0.1:6697]
--tls-cert <PATH> TLS certificate PEM file
--tls-key <PATH> TLS private key PEM file
--server-name <NAME> Server name [default: freeq]
--challenge-timeout-secs <N> SASL challenge validity [default: 60]
--config <PATH> Read options from a TOML file (flags override)
--check-config Validate configuration and exit
--db-path <PATH> SQLite database path (omit for in-memory)
--migrate-to <VERSION> Run the schema ladder to VERSION and exit
--web-addr <ADDR> HTTP/WebSocket listener address
--iroh Enable iroh QUIC transport
--iroh-port <PORT> UDP port for iroh (default: random)
--s2s-peers <ID,ID,...> S2S peer iroh endpoint IDs
--s2s-peer-api <ID=URL,...> Where each S2S peer serves its users' signing keys
When --db-path is set, the server persists:
Without --db-path, the server runs entirely in-memory. The database uses SQLite with WAL mode for good concurrent read performance. Persistence failures are logged but do not crash the server.
Every flag can also live in a TOML file passed via --config server.toml (or FREEQ_CONFIG): keys are the flag names with underscores (listen_addr = "0.0.0.0:6667"), lists are arrays, and precedence is CLI flag > environment variable > file > default, so existing flag-based deployments work unchanged. A typo'd key is a startup error that names the key. --migrate-to is deliberately CLI-only. A full worked example ships as server.toml.example, and a test keeps it in sync with the real schema.
Schema migrations run automatically (and upward only) at startup. A server binary refuses to open a database stamped with a newer schema than it knows, so before rolling back to an older binary, downgrade the schema first: freeq-server --db-path <PATH> --migrate-to <VERSION> runs the ladder to the named version and exits. Downgrades stop with an error at any migration that is irreversible by design.
See deploy/README.md for example VPS setup and deployment instructions.
# Unit + integration tests
cargo test
# S2S federation acceptance tests (9 tests, requires two live servers)
LOCAL_SERVER=localhost:6667 REMOTE_SERVER=irc.freeq.at:6667 \
cargo test -p freeq-server --test s2s_acceptance -- --nocapture --test-threads=1
153 tests covering:
CAP LS 302 / CAP REQ / CAP ENDmessage-tags capability follows the IRCv3 message tags specificationcontent-type, media-url, etc.)iroh=<endpoint-id> in CAP LS for transport discoveryATPROTO-CHALLENGE could be proposed as an IRCv3 WG mechanismFreeq supports a plugin system for custom server behavior. Plugins hook into events like authentication, message delivery, and channel joins.
# Load a plugin via CLI
freeq-server --plugin "identity-override:handle=timesync.bsky.social,display_id=3|337"
# Load plugins from a directory of TOML configs
freeq-server --plugin-dir ./examples/plugins/
See examples/plugins/ for example configurations and docs/PROTOCOL.md
for the full plugin hook reference.
MIT
This listing does not have a supported local package template. Use the maintainer’s documentation for its hosted endpoint, authentication, and client-specific setup. No install command has been inferred.