IRC client/server stack for the Lilush shell.
This lilpack ships three things in one package:
A Lua library for IRC — circada.protocol, circada.client,
circada.server, circada.store, circada.bouncer. RFC 2812 +
IRCv3 (CAP, SASL PLAIN, server-time, message-tags, echo-message,
batch, account-tag, away-notify), built on Lilush's async runtime
(LEV) and TLS 1.3 stack
(LITLS).
circada — a long-running multi-network IRC bouncer daemon.
Maintains your upstream connections, persists scrollback in
MNEME db, exposes a circada-aware single-connection multiplexed
local IRC endpoint for clients.
circada shell mode — a Lilush mode that connects to the
bouncer and gives you a real-time chat UI: per-channel buffers,
alt-screen view, slash commands, theme-driven rendering.
This document primarily describes the bouncer and shell mode. The
standalone Redis-backed IRC server (circada-server, circadactl-server,
circada.server) is documented separately in SERVER.md.
┌────────────────────────────┐
│ Lilush shell │
│ ┌──────────────────────┐ │
│ │ circada mode (UI) │ │
│ └─────────┬────────────┘ │
│ │ TCP loopback │
└────────────┼───────────────┘
│ IRC + circada.io/multiplex CAP
│ PASS=<loopback_token>
┌────────────▼───────────────┐
│ circada bouncer (daemon) │
│ ┌──────────────────────┐ │
│ │ multiplexing router │ │
│ └─┬──────────┬──────┬──┘ │
│ │ │ │ │
│ ┌──▼──┐ ┌──▼──┐ ┌─▼──┐ │
│ │libera◀──▶│oftc │ │… │ │ TLS 1.3 outbound
│ └─────┘ └─────┘ └────┘ │
└────────────────────────────┘
│
▼
~/.local/share/lilush/circada.mneme
(networks, scrollback, secrets, state)
Wire-protocol detail: every cross-network frame on the loopback
carries an IRCv3 message-tag +circada/net=<network_id>. Inbound it
tells the mode which network the frame is from; outbound it selects
the destination upstream. Untagged PRIVMSG to the pseudo-target
*circada is reserved for admin commands. The loopback endpoint is
for circada-capable clients only: clients must negotiate the
circada.io/multiplex CAP and understand these tags.
CIRCADA is a LILPACK, so the standard pack/install flow applies.
Install the latest official version in Lilush Shell: lilpack install circada
After install, restart the shell so the new mode is loaded. To bind
the mode to an F-key, edit ~/.config/lilush/modes.json:
{ "F6": "circada" }
Point CIRCADA_ENCRYPTION_KEY at an unencrypted ed25519 SSH key
export CIRCADA_ENCRYPTION_KEY=~/.ssh/id_ed25519
Without this set the bouncer refuses to start.
Create the bouncer config in ~/.config/circada/config.json:
{
"host": "127.0.0.1",
"port": 6688,
"networks": [
{
"network_id": "libera",
"server": {
"host": "irc.libera.chat",
"port": 6697,
"tls": true,
"sasl": true
},
"auth": {
"nick": "your_nick",
"user": "your_nick",
"realname": "Your Name"
},
"exec_on_connect": ["JOIN #lilush"]
}
]
}
caps is optional; omit to negotiate the built-in default
(sasl, away-notify, echo-message, message-tags,
server-time, account-tag, account-notify, extended-join,
batch, chghost).
Launch the bouncer:
circada
On a fresh install the bouncer comes up but skips any
sasl=true network that has no stored password. You'll see a line
like:
[circada.bouncer] [libera] no stored password — skipping; set via /circada password set libera <pwd>
That's expected. Daemonize it freely, no TTY input is ever needed.
tls_cafile, tls_verify and other TLS knobs default to sensible
values (verify on, /etc/ssl/certs/ca-certificates.crt as the trust
store), so the config above is everything you need on a typical
Debian/Ubuntu/Arch box.
Set the password from inside the shell mode. In a Lilush shell, press F6 (or whichever F-key you mapped) to enter the mode:
/connect
/circada password set libera <your-nickserv-password>
The bouncer encrypts the password into the secrets keyspace and
attempts to spawn the upstream supervisor. The mode opens a
*circada admin buffer with the reply:
<*circada> password set for libera — connecting
<*circada> if no connect appears within ~10s, restart: pkill circada; circada
The auto-spawn path is best-effort. If you don't see libera
connection events ([libera] supervisor started in the bouncer log,
or JOIN events arriving in /window libera #lilush) within a few
seconds, restart the bouncer — the password is already persisted in
the encrypted store, so the boot-time supervisor will pick it up and
connect normally.
Start using IRC:
/window libera #lilush
You're in. ESC leaves the alt-screen view back to the prompt. ^N /
^P cycle buffers. ^O toggles the nicks pane. PgUp / PgDn
scroll. TAB completes nicks (or commands when the input starts with
/). /help lists slash commands.
From this point on, restarting the bouncer (pkill circada; circada)
brings libera back online automatically — the password lives
encrypted in the store.
~/.config/circada/config.json is read by the daemon at startup. The
file is optional — networks can also be added at runtime via
circadactl network add. When both sources define the same
network_id, the file wins on each restart (it's upserted into
the store).
| Field | Type | Default | Description |
|---|---|---|---|
host | string | "127.0.0.1" | Loopback bind address |
port | int | 6688 | Loopback listen port |
require_auth | bool | true | Require PASS=token from local clients |
scrollback_replay | int | 50 | Messages replayed per channel on mode connect |
scrollback_keep | int | 10000 | Messages kept per channel before trim |
trim_interval | int | 300 | Seconds between scrollback-trim sweeps |
ping_interval | int | 90 | Seconds between PINGs to idle local clients |
shutdown_grace | int | 5 | Seconds to wait after SIGTERM before forcing exit |
store.path | string | ~/.local/share/lilush/circada.mneme | MNEME database path |
store.encryption_key_file | string | unset | SSH ed25519 key to encrypt the DB at rest (optional, see Security below) |
networks | array | [] | Upstream IRC network configs (see below) |
| Field | Type | Required | Description |
|---|---|---|---|
network_id | string | yes | Stable identifier (libera, oftc, …); used in tags, CLIs, the *circada admin pseudo |
server.host | string | yes | Upstream hostname (resolved via system DNS) |
server.port | int | yes | Upstream port |
server.tls | bool | no | Use TLS 1.3 (LITLS). Default: false |
server.tls_verify | bool | no | Verify peer cert. Default: true when tls=true |
server.tls_cafile | string | no | CA bundle path. Default: /etc/ssl/certs/ca-certificates.crt |
server.tls_hostname | string | no | SNI override; defaults to server.host |
server.sasl | bool | no | Perform SASL PLAIN after CAP REQ |
auth.nick / auth.user / auth.realname | string | yes | Identity sent in NICK/USER. No password field. |
caps | array | no | Override the default IRCv3 CAP list. Omit to use the built-in default. |
exec_on_connect | array | no | Raw IRC frames sent immediately after registration (e.g. autojoin) |
There is exactly one place SASL passwords live: the encrypted
secrets keyspace inside ~/.local/share/lilush/circada.mneme. The
keyspace uses MNEME's per-keyspace AEAD encryption (ChaCha20-Poly1305)
keyed off the file pointed to by CIRCADA_ENCRYPTION_KEY. This is the
same mechanism the Lilush shell itself uses for its own secrets — a
single SSH ed25519 key unlocks every component.
export CIRCADA_ENCRYPTION_KEY=~/.ssh/id_ed25519
Put it in your shell init (.profile, init.lsh, etc.) so every
process that opens the circada DB sees it: the daemon, circadactl,
the shell mode. Without it set, the bouncer refuses to start
(encryption not configured); circadactl status and the mode also
fail to load secrets.
The key file must be an unencrypted OpenSSH ed25519 private key. If yours is passphrase-protected, generate a separate one purely for Lilush data-at-rest and chmod 600 it.
Always done live, from inside the shell mode, via the /circada
admin command. There is no startup prompt.
First-time activation of a network (sasl=true, no stored password):
/circada password set libera mynickservpassword
The bouncer:
Validates the network exists in the store (typo guard).
Stores the password encrypted at secrets:sasl:libera.
Attempts to spawn the upstream supervisor for libera.
Replies password set for libera — connecting and a fallback
hint (if no connect appears within ~10s, restart: pkill circada; circada).
The auto-spawn path is best-effort. If it doesn't bring libera
online (no [libera] supervisor started log line within a few
seconds), the password is still safely persisted — restart the
bouncer and the boot-time supervisor will pick it up.
Rotation of an already-running network: same command. The bouncer:
Updates the encrypted secret.
Pokes the new password into the live client config.
Force-reconnects libera (other networks unaffected).
Replies password rotated for libera — reconnecting.
Clearing a password (rare; useful when migrating identities or disabling SASL):
/circada password del libera
circada — bouncer daemoncircada # uses ~/.config/circada/config.json
circada -c /path/cfg.json # explicit config path
CIRCADA_CONFIG=/etc/circada/config.json circada
Behavior:
Generates a loopback PASS token on first run (stored encrypted at
secrets:local:auth_token); reuses it forever after.
Binds the loopback listener up-front; aborts cleanly with a single log line if the port is already in use.
For each sasl=true network, looks up an encrypted password. If
one is found, spawns the supervisor with exponential-backoff
reconnect (1, 2, 4, 8, 16, 32, … capped at 300s). If not, logs
and skips the network — set the password live with
/circada password set to bring it online.
SIGTERM triggers graceful shutdown — sends QUIT to all upstreams,
closes the listener, exits within shutdown_grace seconds.
SIGHUP re-reads ~/.config/circada/config.json (or the path passed
to -c). Newly-declared networks get upserted into the store and
spawned; already-running networks are left alone (host/port changes
on a live upstream require an explicit password set, which
force-reconnects, or a full restart).
SIGUSR1 prints a task-tree dump (LEV's debug feature) to stderr. Useful when an upstream goes silent.
Set CIRCADA_DEBUG=1 to log every IRC frame in/out (verbose). Don't
combine with password set — see Secrets handling caveat above.
Drop the following at ~/.config/systemd/user/circada.service:
[Unit]
Description=CIRCADA IRC bouncer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Resolve ~ inside the unit (systemd does NOT expand it for ExecStart).
ExecStart=%h/.local/bin/circada
Environment=CIRCADA_ENCRYPTION_KEY=%h/.ssh/id_ed25519
# Optional: explicit config path (defaults to ~/.config/circada/config.json).
# Environment=CIRCADA_CONFIG=%h/.config/circada/config.json
Restart=on-failure
RestartSec=5
# Graceful shutdown matches `shutdown_grace` in config.json (default 5s).
TimeoutStopSec=10
# Lock the bouncer's filesystem footprint down a notch — it only needs
# its own state dir, the config, and the SSH key.
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=%h/.local/share/lilush %h/.config/circada
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=default.target
Then:
systemctl --user daemon-reload
systemctl --user enable --now circada.service
journalctl --user -u circada.service -f # tail the log
systemctl --user reload circada.service # SIGHUP — reload config
systemctl --user restart circada.service # SIGTERM + restart
systemctl --user reload issues SIGHUP to the running bouncer (same
effect as circadactl reload or kill -HUP <pid>). To survive logout
on a non-graphical session, enable lingering once:
loginctl enable-linger $USER
circadactl — control CLIcircadactl status
circadactl token
circadactl networks
circadactl network show <id>
circadactl network add <id> <host> <port>
[--tls] [--sasl]
[--nick <nick>] [--user <user>]
[--realname <text>]
circadactl network remove <id>
circadactl reload
Read-only commands (status, token, networks, network show)
and reload work while the bouncer is running. Mutating commands
(network add/remove) need the writer flock, so stop the bouncer
first OR use the live admin equivalents — /circada network add /
/circada network remove from the mode — which talk to the running
bouncer and don't fight for the flock:
pkill circada
circadactl network add hackint irc.hackint.org 6697 --tls --sasl \
--nick mynick --user mynick
circada # bouncer comes up; hackint is skipped (no password yet)
Then in the shell mode:
/circada password set hackint <pwd> # spawns the supervisor; connects
circadactl reload sends SIGHUP to the running bouncer (pid is read
from the store's meta keyspace) and triggers a config re-read — same
effect as systemctl --user reload circada.service or
/circada reload from the shell mode. New networks declared in the
config file get upserted; already-connected networks are untouched.
circadactl token prints the loopback PASS token; the shell mode
reads it automatically, but you can use it to plug weechat /
irssi / any other IRC client into the bouncer too.
| Command | Effect |
|---|---|
/connect | Open the loopback connection to the bouncer |
/disconnect, /quit | Close the loopback connection |
/status | Print connection state |
/networks | List networks the mode has seen activity from |
/buffers | List active buffers (network/target, msgs, unread) |
/window <net> <target>, /w … | Switch active buffer and enter the alt-screen chat view |
/join <net> <chan>, /j … | JOIN on the named network |
/part <net> <chan> [reason] | PART on the named network |
/topic <net> <chan> [text] | View/set channel topic |
/msg <net> <target> <text> | Send a PRIVMSG (any network, any target) |
/whois <net> <nick> | WHOIS lookup |
/raw <ircline> | Send an arbitrary IRC frame on the active buffer's network |
/mentions | List recent own-nick mentions across networks (network/buffer/sender/text) |
/search <query> | BM25 search across all scrollback. Results stream into the *circada admin buffer |
/identity [keyfile] | Load/register your encrypted-channel identity on the active network |
/verify <net> <account> <fingerprint> | Mark an account fingerprint as explicitly verified in local trust store |
/enc fetch <net> <chan> | Request channel key wraps (CKEY FETCH) for an encrypted channel |
/enc pending | List pending wrap requests waiting for your approval |
/enc allow <id> / /enc deny <id> | Approve/deny a queued wrap request |
/circada <cmd> | Send admin command to the bouncer. Supported: help, status, networks, password set/del <net> [<pwd>], network add/remove/show/edit <id> ..., search <query>, reload |
/help | List commands |
The mode prompt completes:
Position 1 (the command itself): every slash command above matches by prefix.
Position 2 (network selector for /window, /w, /join,
/j, /part, /topic, /msg, /whois): networks the mode has
observed events from this session.
Position 3:
/window, /w, /msg → channels and DM buffers for the
network at position 2.
/join, /j, /part, /topic → channels only.
/whois → nicks observed across all known channels of that
network.
/circada admin grammar is independent: pos 2 completes the
static subcommand list (help, status, networks, password,
network, search, reload); pos 3 after password completes
set/del; pos 4 after password set|del completes a configured
network_id.
Network IDs auto-seed on every /connect via a *circada networks
round-trip, so /window <TAB> works on a fresh DB before any
channels have populated buffers. Channel/DM completion at pos 3 reads
the live buffer cache, so newly-joined channels and newly-seen nicks
become completable as soon as their JOIN/NAMES frames arrive.
TAB extends the token under the cursor to the longest common prefix
that matches:
a slash command from the static list, when the input starts with
/;
a member nick of the active channel, otherwise. A nick at the start
of the input gets the irssi : trailer (alice<TAB> → alice: ).
No candidate list is rendered — the behaviour mirrors the lilush
shell prompt. If TAB can't make progress, nothing visible happens;
type more and try again.
/window)Continuous-typing input model; everything you type becomes a PRIVMSG
to the active buffer unless it starts with /.
| Key | Action |
|---|---|
ENTER | Send the line (or run the slash command) |
BACKSPACE / DELETE | Edit input |
LEFT / RIGHT / HOME / END | Cursor movement |
^A / ^E | Jump to start / end of input |
^U | Clear input |
TAB | Complete a nick (or a slash command if the input starts with /) |
PgUp / PgDn | Scroll the message log |
^N / ^P | Cycle to next / previous buffer |
^O | Toggle the nicks pane (requires terminal width ≥ 90 cols) |
^G | Toggle concise event view |
^L | Force repaint |
ESC | Leave the view, return to the mode prompt |
The view is a 3-zone alt-screen:
┌────────────────┬───────────────────────────────────────┬──────────────┐
│ buffer sidebar │ message log │ nicks pane │
│ (active row │ (scrollable, newest at bottom, │ (toggle ^O, │
│ highlighted, │ per-nick stable colour, mentions │ ops/voice │
│ unread badge) │ in the mention style) │ styled) │
├────────────────┴───────────────────────────────────────┴──────────────┤
│ status line — scroll, unread/mention badges, conn state, flash slot │
│ > input │
└───────────────────────────────────────────────────────────────────────┘
The sidebar collapses below ~60 cols and the nicks pane below ~90 cols; the view falls back to single-pane gracefully on small terminals. Resizing the terminal reflows the layout on the next idle tick.
The status line shows scroll position, unread counts for other
buffers, mention badges (*[net/target:n] in the mention style), the
connection state, and a flash slot for slash-command output. Server
notices addressed to * are routed into the per-network *status
buffer rather than opening a literal * buffer. ^G toggles concise
event view for the current view session; in concise mode
JOIN/PART/QUIT/AWAY/ACCOUNT lines are hidden from the channel log and
the latest hidden event is shown transiently in the status line.
Concise mode omits the keybinding hint from the status line so dynamic
updates have more room.
This section describes the full user workflow for server-managed
encrypted channels (+E) when you use the CIRCADA shell mode through
the bouncer.
Channel message payloads are end-to-end encrypted between clients.
The standalone server and bouncer relay ciphertext and orchestration notices; they do not decrypt payloads.
IRC metadata still exists as plaintext (channel name, sender account, timestamps, membership/modes/events).
Encrypted payload transport uses ordinary PRIVMSG with +circada/*
tags and opaque body.
Connect and wait until you're SASL-logged in.
Register your encryption identity:
/identity [optional-path-to-ed25519-openssh-key]
Notes:
On first use, you must pass a key path explicitly.
After a successful /identity <path>, the mode remembers that path
for the current mode session, so later /identity can omit it.
Registration is bound to the authenticated IRC account.
Server verifies signature and stores only public material (ed25519/x25519/fingerprint).
On the standalone CIRCADA server (not generic IRC networks), a channel operator enables encrypted orchestration with:
/mode <net> #chan +E
Server behavior:
forces +R and +Z for the channel;
sends CKEY INIT_REQUEST #chan 1 ... to the enabler.
Client behavior (mode):
generates a fresh channel key for epoch 1;
self-wraps it and sends CKEY INIT;
caches the key locally.
-E is not supported (server rejects it).
When another logged-in user joins +E channel:
Join succeeds (subject to normal channel policy: invites/bans/etc).
If the joiner has a registered identity, server requests a wrap from
an eligible online member that already has the epoch key:
CKEY WRAP_REQUEST #chan <joiner> ....
In MVP, wrap confirmation is command-driven:
/enc pending
/enc allow <id> # sends CKEY WRAP
/enc deny <id> # sends nothing
If denied (or no eligible wrapper exists), joiner remains in channel but cannot decrypt encrypted payloads yet.
Manual fetch:
/enc fetch <net> #chan
This requests CKEY FETCH and returns wraps for epochs available to
your account. The mode attempts unwrap and caches recovered keys.
The mode also auto-requests fetch when you try to send into encrypted channel without a cached key.
Outbound to encrypted channel: mode encrypts plaintext, attaches
+circada/enc, +circada/epoch, +circada/nonce, +circada/seq,
and sends ciphertext payload.
Inbound encrypted PRIVMSG: mode decrypts before rendering.
If decrypt fails (missing key/tags/account/mismatch), mode shows a placeholder instead of plaintext.
Server-side channel gates (+n, +m, +R, +Z, bans, invites, etc.)
still apply as usual.
When a member parts/is kicked/quits from initialized encrypted channel, server asks a remaining eligible member to rotate:
CKEY ROTATE_REQUEST #chan <new_epoch>
one or more CKEY ROTATE_MEMBER #chan <new_epoch> <account> <identity_blob>
The shell mode now performs the staged rotation flow automatically once it has the recipient identities for the remaining members:
sends CKEY ROTATE_BEGIN #chan <new_epoch>
sends CKEY ROTATE_WRAP once per remaining member
sends CKEY ROTATE_COMMIT #chan <new_epoch> <signature_b64>
If no eligible rotator is online, server records audit
rotation_needed_no_rotator and channel stays on old epoch until a
keyed member can rotate.
The mode keeps local trust records in ~/.local/share/lilush/shell.mneme
(keyspace circada_enc_trust) per (network, account):
first-seen fingerprint (TOFU)
verified fingerprint (explicit)
changed flag
Explicit verification:
/verify <net> <account> <fingerprint>
Nicks pane badges:
✓ verified
~ known (TOFU seen)
! changed
? unknown
+E orchestration (IDENTITY/CKEY) requires CIRCADA standalone
server support. Through the bouncer on regular external IRC networks,
these semantics are not provided by the upstream server.
The bouncer's loopback endpoint requires a single CAP that vanilla IRC servers don't:
CAP LS 302
:circada CAP * LS :circada.io/multiplex server-time message-tags echo-message batch account-tag away-notify
After registration, every routed frame is tagged:
@+circada/net=libera;time=2026-04-30T12:34:56.789Z :alice!~alice@host PRIVMSG #lilush :hello
The mode (or any circada-aware client) reads +circada/net= to know
which network the frame is from. To send to a specific network, it
prepends the same tag on outbound frames. The bouncer strips
+circada/* tags before forwarding upstream so plain IRC servers
never see them. Clients that do not negotiate circada.io/multiplex
are rejected during registration.
The pseudo-target *circada accepts admin queries:
PRIVMSG *circada :status
PRIVMSG *circada :networks
PRIVMSG *circada :password set <network> <password>
PRIVMSG *circada :password del <network>
PRIVMSG *circada :network add <id> <host> <port> [--tls] [--sasl]
[--nick X] [--user X] [--realname X]
PRIVMSG *circada :network remove <id>
PRIVMSG *circada :network show <id>
PRIVMSG *circada :network edit <id> <field> <value>
PRIVMSG *circada :search <query>
PRIVMSG *circada :reload
PRIVMSG *circada :help
Replies arrive tagged +circada/net=*circada so the mode routes them
into the dedicated (*circada, *circada) admin buffer. The mode also
sniffs *circada networks replies during a 5s window after every
/connect to seed the prompt-side completion source — those replies
are swallowed so they don't bump unread on the admin buffer.
TLS 1.3 only. LITLS doesn't speak older protocols. Libera, OFTC, Hackint, Tilde, Snoonet, and most modern networks support it. A few niche / legacy IRC servers don't.
CIRCADA_ENCRYPTION_KEY is required. The bouncer refuses to start
without it. Both the SASL passwords and the loopback PASS token
live in the encrypted secrets keyspace of
~/.local/share/lilush/circada.mneme; nothing sensitive is on disk
in plaintext. Keep the key file chmod 600 (and the DB file
chmod 600 too — its content is encrypted but the file's existence
and approximate size leak metadata).
Loopback PASS auth. The loopback token in secrets:local:auth_token
protects the bouncer against drive-by connects from other local users.
On a single-user machine this is largely belt-and-suspenders; on a
multi-user box, it's the only thing keeping a curious local user from
attaching to your IRC.
/circada password set traverses the loopback IRC stream.
Disable CIRCADA_DEBUG first; otherwise the password gets logged
to stderr alongside every other frame. The mode-side echo redacts
the password (> password set <net> ******), so the local
*circada buffer is safe to leave open.
Encryption key rotation. circadactl rotate-encryption to add
a new authorized SSH key without losing access via the old one
(MNEME supports this; just needs CLI wiring).
Backfill /search index from existing scrollback. Newly-
arrived messages get indexed; pre-existing scrollback rows stay
invisible to BM25 until they're re-sent. A one-shot scan that
walks scrollback:* / pms:* and re-indexes would close the gap.
References used during implementation:
modern.ircdocs.horse — IRC + IRCv3 spec.
RFC 2812 — base IRC protocol.
RFC 4616 — SASL PLAIN.
ZNC — bouncer architecture inspiration; the multiplex-via-IRCv3-tag idea is a refinement on ZNC's PASS-encoded network selector.
LicenseRef-OWL-1.0-or-later.