Writing a module
echo is a small core with a set of module crates around it. A module depends on one crate, echo-api, and nothing else: not the engine, not the storage, not the network code. That crate carries the traits a module implements and the normalized vocabulary the engine speaks. If your module compiles against echo-api, the core can run it.
There are two kinds of module. A service (a pseudo-client like NickServ) implements Service. A protocol (an ircd link like InspIRCd) implements Protocol. Both live in their own crate under modules/. Copy modules/example/ for a minimal service, or modules/protocol/inspircd/ for the reference protocol.
A service, end to end
1. The crate. One dependency:
# modules/mymod/Cargo.toml
[package]
name = "echo-mymod"
version = "0.0.1"
edition = "2021"
[dependencies]
echo-api = { path = "../../api" }
2. The service. A plain struct implementing Service:
use echo_api::{NetView, Sender, Service, ServiceCtx, Store};
pub struct MyServ {
pub uid: String, // assigned by the daemon
}
impl Service for MyServ {
fn nick(&self) -> &str { "MyServ" }
fn uid(&self) -> &str { &self.uid }
fn gecos(&self) -> &str { "My Service" }
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store) {
// args[0] is the command; reply by pushing onto ctx.
ctx.notice(&self.uid, from.uid, "hello");
}
}
on_command is the whole surface. What you are handed:
from: &Senderis who sent it:uid,nick, andaccount(set once the user is logged in).args: &[&str]is the message split on spaces;args[0]is the command.ctx: &mut ServiceCtxis the only way to affect anything. You push intents and the engine performs them:notice,loginandlogout,channel_mode,kick,topic,invite,force_nick,send_email, and the deferreddefer_registeranddefer_password, whose expensive key derivation the engine runs off-thread.net: &dyn NetViewis a read-only view of the live network:uid_by_nick,nick_of,host_of,account_of,is_op,channel_members,last_seen.store: &mut dyn Storeis the account and channel store. Reads hand back plain views (AccountView,ChannelView, ...) carrying only non-secret fields, never a password hash or SCRAM verifier. Writes are ordinary methods (set_email,register_channel,access_add, ...). The log, gossip, and credential material are not reachable from here, by design.
A change you commit through store replicates to every other node the same way an IRC-originated one does. You write no replication code.
3. Register it. Add the crate to the workspace and to the daemon:
# Cargo.toml
[workspace]
members = [..., "modules/mymod"]
[dependencies]
echo-mymod = { path = "modules/mymod" }
Construct it in src/main.rs alongside the others, behind its config name:
if enabled("mymod") {
services.push(Box::new(echo_mymod::MyServ {
uid: format!("{}AAAAAD", cfg.server.sid), // a stable, unique suffix
}));
}
4. Enable it. In the config:
[modules]
services = ["nickserv", "chanserv", "mymod"]
Omitting [modules] starts the full standard suite. A name that isn't built in is ignored.
A protocol module
A protocol crate implements Protocol: it turns raw server-to-server lines into the normalized NetEvents the engine understands, and turns the engine's NetActions back into raw lines. The engine never sees a raw line, so supporting another ircd is one new crate under modules/protocol/. Start from modules/protocol/inspircd/ and wire it in src/main.rs where InspIrcd is constructed.
What the SDK deliberately withholds
A module cannot reach the append-only log, the gossip layer, the storage engine, or any credential material. It reads through views and writes through curated methods. That is the boundary: a module can be wrong without being dangerous.