diff --git a/xtrn/mrc/mrc-client.example.ini b/xtrn/mrc/mrc-client.example.ini
new file mode 100644
index 0000000000000000000000000000000000000000..879cdea556ab93338061465b1a8d590cead9ce65
--- /dev/null
+++ b/xtrn/mrc/mrc-client.example.ini
@@ -0,0 +1,10 @@
+server = localhost
+port = 5000
+ping_interval = 60
+
+[startup]
+room = lobby
+motd = true
+banners = true
+
+[aliases]
diff --git a/xtrn/mrc/mrc-client.js b/xtrn/mrc/mrc-client.js
new file mode 100644
index 0000000000000000000000000000000000000000..d425291ef5ffd8c50a25c1c83b3f3c9e6529b469
--- /dev/null
+++ b/xtrn/mrc/mrc-client.js
@@ -0,0 +1,351 @@
+/* $Id$ */
+
+/**
+ * Multi Relay Chat Client Module
+ * echicken -at- bbs.electronicchicken.com
+ *
+ * I started out with good intentions.  At least it works.
+ */
+
+load('sbbsdefs.js');
+load('frame.js');
+load('scrollbar.js');
+load('inputline.js');
+load(js.startup_dir + 'mrc-session.js');
+
+var f = new File(js.startup_dir + 'mrc-client.ini');
+f.open('r');
+const settings = {
+    root: f.iniGetObject(),
+    startup: f.iniGetObject('startup'),
+    aliases: f.iniGetObject('aliases') || {}
+};
+f.close();
+f = undefined;
+
+const NICK_COLOURS = [
+    '\1h\1r',
+    '\1h\1g',
+    '\1h\1y',
+    '\1h\1b',
+    '\1h\1m',
+    '\1h\1c',
+    '\1h\1w',
+    // Low colours with reasonable contrast
+    '\1n\1r',
+    '\1n\1g',
+    '\1n\1y',
+    '\1n\1m',
+    '\1n\1c',
+    '\1n\1w'
+];
+
+const PIPE_COLOURS = [2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15];
+
+function pipe_to_ctrl_a(str) {
+	str = str.replace(/\|00/g, "\1N\1K");
+	str = str.replace(/\|01/g, "\1N\1B");
+	str = str.replace(/\|02/g, "\1N\1G");
+	str = str.replace(/\|03/g, "\1N\1C");
+	str = str.replace(/\|04/g, "\1N\1R");
+	str = str.replace(/\|05/g, "\1N\1M");
+	str = str.replace(/\|06/g, "\1N\1Y");
+	str = str.replace(/\|07/g, "\1N\1W");
+	str = str.replace(/\|08/g, "\1H\1K");
+	str = str.replace(/\|09/g, "\1H\1B");
+	str = str.replace(/\|10/g, "\1H\1G");
+	str = str.replace(/\|11/g, "\1H\1C");
+	str = str.replace(/\|12/g, "\1H\1R");
+	str = str.replace(/\|13/g, "\1H\1M");
+	str = str.replace(/\|14/g, "\1H\1Y");
+	str = str.replace(/\|15/g, "\1H\1W");
+	str = str.replace(/\|16/g, "\001" + 0);
+	str = str.replace(/\|17/g, "\001" + 4);
+	str = str.replace(/\|18/g, "\001" + 2);
+	str = str.replace(/\|19/g, "\001" + 6);
+	str = str.replace(/\|20/g, "\001" + 1);
+	str = str.replace(/\|21/g, "\001" + 5);
+	str = str.replace(/\|22/g, "\001" + 3);
+	str = str.replace(/\|23/g, "\001" + 7);
+	return str;
+}
+
+function resize_nicklist(frames, nicks) {
+    const maxlen = Math.max(1, nicks.reduce(function (a, c) {
+        return c.length > a ? c.length : a;
+    }, 0));
+    frames.nicklist.moveTo(frames.top.x + frames.top.width - 1 - maxlen - 1, 2);
+    frames.nicklist_divider.moveTo(frames.nicklist.x, 2);
+    frames.nicks.moveTo(frames.nicklist.x + 1, 2);
+    frames.nicklist.width = maxlen + 2;
+    frames.nicks.width = maxlen + 1;
+    frames.output.width = frames.top.width - frames.nicklist.width - 1;
+}
+
+function redraw_nicklist(frames, nicks, colours) {
+    frames.nicks.clear();
+    nicks.forEach(function (e, i) {
+        frames.nicks.gotoxy(1, i + 1);
+        frames.nicks.putmsg(colours[e] + e + '\1n\1w');
+    });
+}
+
+function init_display() {
+    const w = console.screen_columns;
+    const h = console.screen_rows;
+    const f = { top: new Frame(1, 1, w, h, BG_BLACK|LIGHTGRAY) };
+    f.title = new Frame(1, 1, w, 1, BG_BLUE|WHITE, f.top);
+    f.output = new Frame(1, 2, 1, h - 3, BG_BLACK|LIGHTGRAY, f.top);
+    f.divider = new Frame(1, h - 1, w, 1, BG_BLUE|WHITE, f.top);
+    f.nicklist = new Frame(w - 2, 2, 2, h - 3, BG_BLACK|LIGHTGRAY, f.top);
+    f.nicklist_divider = new Frame(w - 2, 2, 1, h - 3, BG_BLACK|LIGHTGRAY, f.nicklist);
+    f.nicks = new Frame(w - 1, 2, 1, h - 3, BG_BLACK|LIGHTGRAY, f.nicklist);
+    f.input = new Frame(1, h, w, 1, BG_BLACK|WHITE, f.top);
+    for (var n = 0; n < f.nicklist_divider.height; n++) {
+        f.nicklist_divider.gotoxy(1, n + 1);
+        f.nicklist_divider.putmsg(ascii(179));
+    }
+    f.output_scroll = new ScrollBar(f.output, { autohide: true });
+    f.nick_scroll = new ScrollBar(f.nicks, { autohide: true });
+    f.output.word_wrap = true;
+    f.divider.gotoxy(f.divider.width - 5, 1);
+    f.divider.putmsg('/help');
+    f.top.open();
+    return f;
+}
+
+function display_message(frames, msg, colour) {
+    const body = pipe_to_ctrl_a(msg.body || '').split(' ');
+    frames.output.putmsg(body[0] + '\1n\1w: ' + body.slice(1).join(' ') + '\1n\1w\r\n');
+}
+
+function display_server_message(frames, msg) {
+    frames.output.putmsg('\1h\1w' + pipe_to_ctrl_a(msg || '') + '\r\n');
+}
+
+function display_title(frames, room, title) {
+    frames.title.clear();
+    frames.title.putmsg('MRC - #' + room + ' - ' + title);
+}
+
+function set_alias(alias) {
+    const f = new File(js.startup_dir + 'mrc-client.ini');
+    f.open('r+');
+    f.iniSetValue('aliases', user.alias, alias);
+    f.close();
+}
+
+function new_alias() {
+    const alias = format(
+        '|%02d%s',
+        PIPE_COLOURS[Math.floor(Math.random() * PIPE_COLOURS.length)],
+        user.alias.replace(/\s/g, '_')
+    );
+    set_alias(alias);
+    settings.aliases[user.alias] = alias;
+}
+
+function main() {
+
+    var msg;
+    var input_state = 'chat';
+    var break_loop = false;
+    const nick_colours = {};
+
+    const session = new MRC_Session(
+        settings.root.server,
+        settings.root.port,
+        user.alias,
+        user.security.password,
+        settings.aliases[user.alias] || new_alias(user.alias)
+    );
+
+    const frames = init_display();
+    const inputline = new InputLine(frames.input);
+    inputline.show_cursor = true;
+    inputline.max_buffer = 140 - settings.aliases[user.alias].length - 1;
+
+    resize_nicklist(frames, []);
+    redraw_nicklist(frames, []);
+
+    session.connect();
+    session.on('banner', function (msg) {
+        display_server_message(frames, msg);
+    });
+    session.on('disconnect', function () {
+        break_loop = true;
+    });
+    session.on('error', function (err) {
+        display_message(frames, { from_user: 'ERROR', body: err });
+    });
+    session.on('help', function (cmd, help, ars) {
+        if (!ars || user.compare_ars(ars)) {
+            display_server_message(frames, format('\1h\1w/\1h\1c%s \1h\1w- \1n\1w%s', cmd, help));
+        }
+    });
+    session.on('local_help', function (msg) {
+        display_server_message(frames, '\1h\1w/\1h\1cscroll \1h\1w- \1n\1wScroll the output area');
+        display_server_message(frames, '\1h\1w/\1h\1cscroll_nicks \1h\1w- \1n\1wScroll the nicklist');
+        display_server_message(frames, '\1h\1w/\1h\1cnick_prefix \1h\1w- \1n\1wSet a single-character prefix for your handle, eg. /nick_prefix @');
+        display_server_message(
+            frames,
+            '\1h\1w/\1h\1cnick_color \1h\1w- \1n\1wSet your nick color to one of '
+            + PIPE_COLOURS.reduce(function (a, c) {
+                a += format('|%02d%s ', c, c);
+                return a;
+            }, '')
+            + ', eg. /nick_color 11'
+        );
+        display_server_message(frames, '\1h\1w/\1h\1cnick_suffix \1h\1w- \1n\1wSet an eight-character suffix for your handle, eg. /nick_suffix <poop>');
+        display_server_message(frames, '\1h\1w/\1h\1cquit \1n\1w- \1h\1wExit the program');
+    });
+    session.on('message', function (msg) {
+        if (msg.from_user == 'SERVER') {
+            display_server_message(frames, msg.body);
+        } else {
+            display_message(frames, msg, nick_colours[msg.from_user] || '');
+        }
+    });
+    session.on('nicks', function (nicks) {
+        nicks.forEach(function (e, i) {
+            nick_colours[e] = NICK_COLOURS[i % NICK_COLOURS.length];
+        });
+        resize_nicklist(frames, nicks);
+        redraw_nicklist(frames, nicks, nick_colours);
+    });
+    session.on('sent_privmsg', function (user, msg) {
+        display_message(frames, { body: '--> ' + (nick_colours[user] || '') + user + ' ' + msg });
+    });
+    session.on('topic', function (room, topic) {
+        display_title(frames, room, topic);
+    });
+
+    if (settings.startup.motd) session.motd();
+    if (settings.startup.banners) session.banners();
+    if (settings.startup.room) session.join(settings.startup.room);
+
+    var cmd, line, user_input;
+    while (!js.terminated && !break_loop) {
+        session.cycle();
+        user_input = inputline.getkey();
+        if (typeof user_input != 'undefined') {
+            if (input_state == 'chat') {
+                if (user_input.substring(0, 1) == '/') { // It's a command
+                    cmd = user_input.split(' ');
+                    cmd[0] = cmd[0].substr(1).toLowerCase();
+                    switch (cmd[0]) {
+                        case 'rainbow':
+                            line = user_input.replace(/^\/rainbow\s/, '').split('').reduce(function (a, c) {
+                                var cc = PIPE_COLOURS[Math.floor(Math.random() * PIPE_COLOURS.length)];
+                                a += format('|%02d%s', cc, c);
+                                return a;
+                            }, '').substr(0, inputline.max_buffer).replace(/\\s\|\d*$/, '');
+                            session.send_room_message(line);
+                            break;
+                        case 'scroll':
+                            input_state = 'scroll';
+                            frames.divider.clear();
+                            frames.divider.putmsg('UP/DOWN to scroll, ENTER to return');
+                            break;
+                        case 'scroll_nicks':
+                            input_state = 'scroll_nicks';
+                            frames.divider.clear();
+                            frames.divider.putmsg('UP/DOWN to scroll nicklist, ENTER to return');
+                            break;
+                        case 'nick_prefix':
+                            if (cmd.length == 2) {
+                                if (cmd[1].length == 1 || cmd[1].search(/^\|\d\d\S$/) == 0) {
+                                    var re = new RegExp('^(\\|\\d\\d)*\\S*' + user.alias, 'i');
+                                    settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, cmd[1] + user.alias);
+                                    set_alias(settings.aliases[user.alias]);
+                                    session.alias = settings.aliases[user.alias];
+                                }
+                            }
+                            break;
+                        case 'nick_color':
+                        case 'nick_colour':
+                            if (cmd.length == 2) {
+                                if (PIPE_COLOURS.indexOf(parseInt(cmd[1])) >= 0) {
+                                    var re = new RegExp('(\\|\\d\\d)*' + user.alias, 'i');
+                                    settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, format('|%02d%s', cmd[1], user.alias));
+                                    set_alias(settings.aliases[user.alias]);
+                                    session.alias = settings.aliases[user.alias];
+                                }
+                            }
+                            break;
+                        case 'nick_suffix':
+                            if (cmd.length == 2) {
+                                if (cmd[1].replace(/(\\|\\d\\d)/g, '').length <= 8) {
+                                    var re = new RegExp(user.alias + '.*$', 'i');
+                                    settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, user.alias + cmd[1]);
+                                    set_alias(settings.aliases[user.alias]);
+                                    session.alias = settings.aliases[user.alias];
+                                }
+                            } else { // Clearing a nick suffix
+                                var re = new RegExp(user.alias + '.*$', 'i');
+                                settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, user.alias);
+                                set_alias(settings.aliases[user.alias]);
+                                session.alias = settings.aliases[user.alias];
+                            }
+                            break;
+                        default:
+                            if (typeof session[cmd[0]] == 'function') {
+                                session[cmd[0]](cmd.slice(1).join(' '));
+                            }
+                            break;
+                    }
+                } else if (user_input == '\t') { // Nick completion
+                    cmd = inputline.buffer.split(' ').pop().toLowerCase();
+                    var nick = '';
+                    session.nicks.some(function (e) {
+                        if (e.substring(0, cmd.length).toLowerCase() != cmd) return false;
+                        nick = e;
+                        return true;
+                    });
+                    if (nick != '') {
+                        // lol this is horrible
+                        // to do: add some methods to inputline
+                        for (var n = 0; n < cmd.length; n++) {
+                            console.ungetstr('\b');
+                            inputline.getkey();
+                        }
+                        for (var n = 0; n < nick.length; n++) {
+                            console.ungetstr(nick[n]);
+                            inputline.getkey();
+                        }
+                    }
+                } else if ( // Regular message
+                    typeof user_input == 'string' // Could be bool at this point
+                    && user_input != ''
+                    && [KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT].indexOf(user_input) < 0
+                ) {
+                    session.send_room_message(user_input);
+                }
+            } else if (input_state == 'scroll' || input_state == 'scroll_nicks') {
+                var sframe = input_state == 'scroll' ? frames.output : frames.nicks;
+                // to do: page up, page down
+                if (user_input == KEY_UP && sframe.offset.y > 0) {
+                    sframe.scroll(0, -1);
+                } else if (user_input == KEY_DOWN && sframe.offset.y + sframe.height < sframe.data_height) {
+                    sframe.scroll(0, 1);
+                } else if (user_input == '' || user_input == 'q') {
+                    frames.output.scrollTo(1, frames.output.data_height - frames.output.height);
+                    frames.output_scroll.cycle();
+                    frames.divider.clear();
+                    frames.divider.gotoxy(frames.divider.width - 5, 1);
+                    frames.divider.putmsg('/help');
+                    input_state = 'chat';
+                }
+            }
+        }
+        if (frames.top.cycle()) {
+            frames.output_scroll.cycle();
+            frames.nick_scroll.cycle();
+            console.gotoxy(console.screen_columns, console.screen_rows);
+        }
+        yield();
+    }
+
+}
+
+main();
diff --git a/xtrn/mrc/mrc-connector.example.ini b/xtrn/mrc/mrc-connector.example.ini
new file mode 100644
index 0000000000000000000000000000000000000000..e256b30863ff7ad87d4ec0839958f690ed983eb1
--- /dev/null
+++ b/xtrn/mrc/mrc-connector.example.ini
@@ -0,0 +1,14 @@
+server = mrc.bottomlessabyss.net
+port = 5000
+timeout = 10
+ping_interval = 60
+reconnect_delay = 30
+
+[info]
+;web = https://vurt.synchro.net:8443/
+;telnet = vurt.synchro.net:23
+;ssh = vurt.synchro.net:22
+;sysop = Bob Swindle
+;description = Vurtrowen
+;system_name = |17|11my custom |03bbs name
+;platform = Not_Synchronet_6.6.6z/Amiga_OS/1.2.9
diff --git a/xtrn/mrc/mrc-connector.js b/xtrn/mrc/mrc-connector.js
new file mode 100644
index 0000000000000000000000000000000000000000..750b0a1ab0902b63a6c31efe39b6c139e8f4936b
--- /dev/null
+++ b/xtrn/mrc/mrc-connector.js
@@ -0,0 +1,278 @@
+/* $Id$ */
+
+/**
+ * Multi Relay Chat connector (multiplexer) service
+ * echicken -at- bbs.electronicchicken.com
+ */
+
+load('sbbsdefs.js');
+load('sockdefs.js');
+
+if (!js.global.server) {
+    server = {};
+    server.socket = new Socket(SOCK_STREAM, 'MRC_PROXY');
+    server.socket.bind(5000, '127.0.0.1');
+    server.socket.listen();
+}
+
+js.branch_limit = 0;
+server.socket.nonblocking = true;
+
+var f = new File(js.exec_dir + 'mrc-connector.ini');
+f.open('r');
+const settings = f.iniGetObject();
+const system_info = f.iniGetObject('info') || {};
+f.close();
+f = undefined;
+
+const PROTOCOL_VERSION = '1.2.9';
+const MAX_LINE = 256;
+const FROM_SITE = system.qwk_id.toLowerCase();
+const SYSTEM_NAME = system_info.system_name || system.name;
+
+const clients = {};
+var last_connect = 0;
+
+// User / site name must be ASCII 33-125, no MCI, 30 chars max, underscores
+function sanitize_name(str) {
+    return str.replace(
+        /\s/g, '_'
+    ).replace(
+        /[^\x21-\x7D]|(\|\w\w)/g, '' // Non-printable & MCI
+    ).substr(
+        0, 30
+    );
+}
+
+// Room name must be ASCII 33-125, no MCI, 30 chars max
+function sanitize_room(str) {
+    return str.replace(/[^\x21-\x7D]|(\|\w\w)/g, '').substr(0, 30);
+}
+
+// Message text must be ASCII 32-125
+function sanitize_message(str) {
+    return str.replace(/[^\x20-\x7D]/g, '');
+}
+
+function strip_mci(str) {
+    return str.replace(/\|\w\w/g, '');
+}
+
+function parse_message(line) {
+    const msg = line.split('~');
+    if (msg.length < 7) {
+        log(LOG_ERR, 'Invalid MRC line: ' + line);
+        return;
+    }
+    return {
+        from_user: msg[0],
+        from_site: msg[1],
+        from_room: msg[2],
+        to_user: msg[3],
+        to_site: msg[4],
+        to_room: msg[5],
+        body: msg[6]
+    };
+}
+
+function validate_message(msg) {
+    return (
+        typeof msg == 'object'
+        && typeof msg.from_room == 'string'
+        && typeof msg.to_user == 'string'
+        && typeof msg.to_site == 'string'
+        && typeof msg.to_room == 'string'
+        && typeof msg.body == 'string'
+    );
+}
+
+function client_receive(c, no_log) {
+    const line = c.socket.recvline(1024, settings.timeout);
+    if (!line || line == '') return;
+    try {
+        if (!no_log) log(LOG_DEBUG, 'From client: ' + line);
+        const msg = JSON.parse(line);
+        return msg;
+    } catch (err) {
+        log(LOG_ERR, 'Invalid message from client: ' + line);
+    }
+}
+
+function client_close(sock, message) {
+    sock.send(JSON.stringify(message) + '\n');
+    sock.close();
+}
+
+function client_accept() {
+    if (!server.socket.poll()) return;
+    var c = server.socket.accept();
+    if (!c) return;
+    c.nonblocking = true;
+    const msg = client_receive({ socket: c }, true);
+    if (!msg) return;
+    if (!msg.username || !msg.password) {
+        log(LOG_ERR, 'Invalid handshake from client: ' + JSON.stringify(msg));
+        client_close(c, { error: 'Invalid handshake' });
+        return;
+    }
+    const un = system.matchuser(msg.username);
+    if (!un) {
+        log(LOG_ERR, 'Invalid username from client: ' + msg.username);
+        client_close(c, { error: 'Invalid username' }); // Leaks user existence
+        return;
+    }
+    var u = new User(un);
+    if (msg.password != u.security.password) {
+        log(LOG_ERR, 'Invalid password for: ' + msg.username);
+        client_close(c, { error: 'Invalid password' });
+        return;
+    }
+    clients[c.descriptor] = {
+        username: sanitize_name(msg.username),
+        socket : c,
+        ping: 0,
+        alias: msg.alias.replace(/\s/g, '_')
+    };
+    c.sendline(JSON.stringify({ error: null }));
+}
+
+// Forward a message to all applicable clients
+function client_send(message, username) {
+    Object.keys(clients).forEach(function (e) {
+        if (!username || clients[e].username == username) {
+            log(LOG_DEBUG, 'Forwarding message to ' + clients[e].username);
+            clients[e].socket.sendline(JSON.stringify(message));
+        }
+    });
+}
+
+function mrc_connect(host, port) {
+    if (time() - last_connect < settings.reconnect_delay) return false;
+    last_connect = time();
+    const sock = new Socket();
+    sock.nonblocking = true;
+    log(LOG_INFO, 'Connecting to ' + host + ':' + port);
+    if (!sock.connect(host, port, settings.timeout)) return false;
+    const platform = format(
+        'Synchronet %s%s/%s/%s',
+        system.version, system.revision, system.platform, PROTOCOL_VERSION
+    ).replace(/\s/g, '_');
+    const line = SYSTEM_NAME + '~' + (system_info.platform || platform);
+    log(LOG_DEBUG, 'To MRC: ' + line);
+    sock.send(line + '\n');
+    while (sock.is_connected) {
+        yield();
+        // Just waiting for the HELLO line; we could verify, but meh
+        if (!sock.data_waiting) continue;
+        log(LOG_DEBUG, 'From MRC: ' + sock.recvline(512, settings.timeout));
+        break;
+    }
+    // Could check if HTTPS is available and if SSH is enabled (sbbs.ini)
+    mrc_send_info(sock, 'WEB', system_info.web || 'http://' + system.inet_addr + '/');
+    mrc_send_info(sock, 'TEL', system_info.telnet || system.inet_addr);
+    mrc_send_info(sock, 'SSH', system_info.ssh || system.inet_addr);
+    mrc_send_info(sock, 'SYS', system_info.sysop || system.operator);
+    mrc_send_info(sock, 'DSC', system_info.description || system.name);
+    return sock;
+}
+
+function mrc_send(sock, from_user, from_room, to_user, to_site, to_room, msg) {
+    if (!sock.is_connected) throw new Error('Not connected.');
+    const m = sanitize_message(msg);
+    const line = [
+        from_user,
+        FROM_SITE,
+        sanitize_room(from_room),
+        sanitize_name(to_user || ''),
+        sanitize_name(to_site || ''),
+        sanitize_room(to_room || ''),
+        m
+    ].join('~') + '~';
+    log(LOG_DEBUG, 'To MRC: ' + line);
+    return sock.send(line + '\n');
+}
+
+function mrc_send_info(sock, field, str) {
+    mrc_send(sock, 'CLIENT', '', 'SERVER', 'ALL', '', 'INFO' + field + ':' + str);
+}
+
+function mrc_receive(sock) {
+    var line, message;
+    while (sock.data_waiting) {
+        line = sock.recvline(MAX_LINE, settings.timeout);
+        if (!line || line == '') break;
+        log(LOG_DEBUG, 'From MRC: ' + line);
+        message = parse_message(line);
+        if (!message) continue;
+        if (message.from_user == 'SERVER' && message.body.toUpperCase() == 'PING') {
+            mrc_send(sock, 'CLIENT', '', 'SERVER', 'ALL', '', 'IMALIVE:' + SYSTEM_NAME);
+            return;
+        }
+        if (['', 'ALL', FROM_SITE].indexOf(message.to_site) > -1) {
+            if (['', 'CLIENT', 'ALL'].indexOf(message.to_site) > -1) {
+                // Forward to all clients
+                client_send(message);
+            } else {
+                // Send to this user
+                client_send(message, message.to_user);
+            }
+        }
+        yield();
+    }
+}
+
+function main() {
+
+    var mrc_sock;
+    var die = false;
+    while (!die && !js.terminated) {
+
+        yield();
+        if (!mrc_sock || !mrc_sock.is_connected) {
+            mrc_sock = mrc_connect(settings.server, settings.port);
+            continue;
+        }
+
+        client_accept();
+
+        Object.keys(clients).forEach(function (e, i) {
+            if (!clients[e].socket.is_connected) {
+                mrc_send(mrc_sock, clients[e].username, '', 'SERVER', '', '', 'LOGOFF');
+                client_send({ from_user: clients[e].username, to_user: 'SERVER', body: 'LOGOFF' }); // Notify local clients
+                delete clients[e];
+            } else {
+                var msg;
+                while (clients[e].socket.data_waiting) {
+                    msg = client_receive(clients[e]);
+                    if (!msg) break;
+                    if (!validate_message(msg)) break; // Log
+                    mrc_send(
+                        mrc_sock,
+                        clients[e].username,
+                        msg.from_room,
+                        msg.to_user,
+                        msg.to_site,
+                        msg.to_room,
+                        msg.body
+                    );
+                    yield();
+                }
+            }
+        });
+
+        mrc_receive(mrc_sock);
+
+    }
+
+    log(LOG_INFO, 'Disconnecting from MRC');
+    mrc_send(mrc_sock, 'CLIENT', '', 'SERVER', 'ALL', '', 'SHUTDOWN');
+    const stime = time();
+    while (mrc_sock.is_connected && time() - stime > settings.timeout) {
+        yield();
+    }
+    if (mrc_sock.is_connected) mrc_sock.close();
+    log(LOG_INFO, 'Disconnected.  Exiting.');
+
+}
+
+main();
diff --git a/xtrn/mrc/mrc-session.js b/xtrn/mrc/mrc-session.js
new file mode 100644
index 0000000000000000000000000000000000000000..3299fe63870708b41c0301dce2b8c2b062eef98f
--- /dev/null
+++ b/xtrn/mrc/mrc-session.js
@@ -0,0 +1,275 @@
+/* $Id$ */
+
+// Passes traffic between an mrc-connector.js server and a client application
+// See mrc-client.js for a bad example.
+function MRC_Session(host, port, user, pass, alias) {
+
+    const handle = new Socket();
+
+    const state = {
+        room: '',
+        room_topic: '',
+        nicks: [],
+        output_buffer: [],
+        last_ping: 0,
+        last_send: 0,
+        alias: alias || user
+    };
+
+    const callbacks = {
+        error: []
+    };
+
+    function send(to_user, to_site, to_room, body) {
+        if (body == '' || body == state.alias) return;
+        state.output_buffer.push({
+            from_room: state.room,
+            to_user: to_user,
+            to_site: to_site,
+            to_room: to_room,
+            body: body
+        });
+    }
+
+    function send_command(command, to_site) {
+        send('SERVER', to_site || '', state.room, command);
+    }
+
+    function send_message(to_user, to_room, body) {
+        if (body.length + state.alias.length + 1 > 140) {
+            word_wrap(body, 140 - 1 - state.alias.length).split(/\n/).forEach(function (e) {
+                send(to_user, '', to_room, state.alias + ' ' + e);
+            });
+        } else {
+            send(to_user, '', to_room, state.alias + ' ' + body);
+        }
+    }
+
+    function emit() {
+        if (!Array.isArray(callbacks[arguments[0]])) return;
+        const rest = Array.prototype.slice.call(arguments, 1);
+        callbacks[arguments[0]].forEach(function (e) {
+            if (typeof e != 'function') return;
+            e.apply(null, rest);
+        });
+    }
+
+    function handle_message(msg) {
+        if (msg.from_user == 'SERVER') {
+            const params = msg.body.split(':');
+            switch (params[0]) {
+                case 'BANNER':
+                    emit('banner', params[1].replace(/^\s+/, ''));
+                    break;
+                case 'ROOMTOPIC':
+                    emit('topic', params[1], params.slice(2).join(' '));
+                    break;
+                case 'USERLIST':
+                    state.nicks = params[1].split(',');
+                    emit('nicks', state.nicks);
+                    break;
+                default:
+                    emit('message', msg);
+                    break;
+            }
+        } else if (msg.to_user == 'SERVER') {
+            if (msg.body == 'LOGOFF') {
+                const uidx = state.nicks.indexOf(msg.from_user);
+                if (uidx > -1) {
+                    state.nicks.splice(uidx, 1);
+                    emit('nicks', state.nicks);
+                }
+            }
+        } else if (msg.to_user == '' || user.toLowerCase() == msg.to_user.toLowerCase()) {
+            if (msg.to_room == '' || msg.to_room == state.room) {
+                emit('message', msg);
+            }
+            if (msg.to_room == state.room
+                && state.nicks.indexOf(msg.from_user) < 0
+            ) {
+                send_command('USERLIST', 'ALL');
+            }
+        } else if (msg.to_user == 'NOTME') {
+            if (msg.body.search(/left\ the\ (room|server)\.*$/ > -1)) {
+                const udix = state.nicks.indexOf(msg.from_user);
+                if (uidx > -1) {
+                    state.nicks.splice(uidx, 1);
+                    emit('nicks', state.nicks);
+                }
+            } else if (msg.body.search(/just joined room/) > -1) {
+                send_command('USERLIST', 'ALL');
+            }
+        }
+    }
+
+    this.send_room_message = function (msg) {
+        send_message('', state.room, msg);
+    }
+
+    this.send_private_messsage = function (user, msg) {
+        msg = '|11(|03Private|11)|07 ' + msg;
+        send_message(user, state.room, msg);
+        emit('sent_privmsg', user, msg);
+    }
+
+    this.send_command = function (command, to_site) {
+        send_command(command, to_site || '');
+    }
+
+    this.connect = function () {
+        handle.connect(host || 'localhost', port || 50000);
+        handle.send(JSON.stringify({
+            username: user,
+            password: pass,
+            alias: state.alias
+        }) + '\r\n');
+        this.send_command('USERLIST', 'ALL');
+    }
+
+    this.disconnect = function () {
+        handle.close();
+    }
+
+    this.on = function (evt, callback) {
+        if (typeof callbacks[evt] == 'undefined') callbacks[evt] = [];
+        callbacks[evt].push(callback);
+        return callbacks[evt].length;
+    }
+
+    this.off = function (evt, id) {
+        if (Array.isArray(callbacks[evt])) callbacks[evt][id] = null;
+    }
+
+    this.cycle = function () {
+        var msg;
+        while (handle.data_waiting) {
+            try {
+                msg = JSON.parse(handle.recvline(), 1024, 10);
+                if (msg.body) handle_message(msg);
+            } catch (err) {
+                callbacks.error.forEach(function (e) {
+                    e(err);
+                });
+            }
+        }
+        if (time() - state.last_ping >= 60) {
+            this.send_command('IAMHERE');
+            state.last_ping = time();
+        }
+        if (state.output_buffer.length && time() - state.last_send >= 1) {
+            handle.send(JSON.stringify(state.output_buffer.shift()) + '\r\n');
+            state.last_send = time();
+        }
+    }
+
+    const commands = {
+        banners: {
+            help: 'List of banners from server'
+        },
+        chatters: {
+            help: 'List current users'
+        },
+        connected: {
+            help: 'List connected BBSs'
+        },
+        help: {
+            help: 'Display this help message',
+            callback: function (str) {
+                emit('help', 'List of available commands:');
+                for (var c in commands) {
+                    emit('help', c, commands[c].help, commands[c].ars);
+                }
+                emit('local_help');
+            }
+        },
+        info: {
+            help: 'View information about a BBS (/info #)',
+            callback: function (str) {
+                this.send_command('INFO ' + str);
+            }
+        },
+        join: {
+            help: 'Move to a new room: /join room_name',
+            callback: function (str) { // validate valid room name?
+                str = str.replace(/^#/, '');
+                this.send_command(format('NEWROOM:%s:%s', state.room, str));
+                state.room = str;
+                state.nicks = [];
+                this.send_command('USERLIST', 'ALL');
+            }
+        },
+        meetups: {
+            help: 'Display information about upcoming meetups'
+        },
+        motd: {
+            help: 'Display the Message of the Day'
+        },
+        msg: {
+            help: 'Send a private message: /msg nick message goes here',
+            callback: function (str) {
+                const cmd = str.split(' ');
+                if (cmd.length > 1 && state.nicks.indexOf(cmd[0]) > -1) {
+                    this.send_private_messsage(cmd[0], cmd.slice(1).join(' '));
+                }
+            }
+        },
+        notify: {
+            help: 'Send a notification message to the server (what?)',
+            callback: function (str) {
+                this.send_command('NOTIFY:' + str, 'ALL');
+            }
+        },
+        quit: {
+            help: 'Quit the program',
+            callback: function () {
+                handle.close();
+                emit('disconnect');
+            }
+        },
+        rooms: {
+            help: 'List available rooms',
+            command: 'LIST'
+        },
+        stats: {
+            help: 'Return anonymous server stats'
+        },
+        topic: {
+            help: 'Change the topic of the current room',
+            callback: function (str) {
+                this.send_command(format('NEWTOPIC:%s:%s', state.room, str));
+            }
+        },
+        users: {
+            help: 'Display list of users'
+        },
+        whoon: {
+            help: 'Display list of users and BBSs'
+        }
+    };
+
+    Object.keys(commands).forEach(function (e) {
+        if (commands[e].callback) {
+            this[e] = commands[e].callback;
+        } else if (commands[e].command) {
+            this[e] = function () {
+                this.send_command(commands[e].command.toUpperCase());
+            }
+        } else {
+            this[e] = function () {
+                this.send_command(e.toUpperCase());
+            }
+        }
+    }, this);
+
+    Object.keys(state).forEach(function (e) {
+        Object.defineProperty(this, e, {
+            get: function () {
+                return state[e];
+            },
+            set: function (v) {
+                state[e] = v;
+            }
+        });
+    }, this);
+
+}
diff --git a/xtrn/mrc/readme.txt b/xtrn/mrc/readme.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e8eb3738fddd127e4efbf3d4a29900a7586d533e
--- /dev/null
+++ b/xtrn/mrc/readme.txt
@@ -0,0 +1,82 @@
+Multi Relay Chat (MRC) client for Synchronet BBS 3.17b+
+echicken -at- bbs.electronicchicken.com
+
+1) Quick Start
+2) Client -> Server -> Server
+3) Customization
+4) Support
+
+
+1) Quick Start
+
+The following assumes that Synchronet is installed at /sbbs or c:\sbbs.  Adjust
+paths accordingly if your setup differs.
+
+- Copy mrc-connector.example.ini to mrc-connector.ini
+- Copy mrc-client.example.ini to mrc-client.ini
+- Edit '/sbbs/ctrl/services.ini' and paste in the following section:
+
+    [MRC-Connector]
+    Port=5000
+    Options=STATIC | LOOP
+    Command=../xtrn/mrc/mrc-connector.js
+
+- In SCFG, add a new External Program with the following options, leaving all
+  other options at their default values:
+
+    Name: MRC
+    Internal Code: MRC
+    Start-up Directory: /sbbs/xtrn/mrc
+    Command Line: ?mrc-client.js
+
+- Your services and BBS threads should automatically recycle once they are free
+  to do so, but you may want to restart your BBS now to force these changes to
+  take effect.
+
+
+2) Client -> Server -> Server
+
+MRC-Connector is a service that runs locally on your BBS.  It maintains a
+connection with a remote MRC server, and passes traffic between the remote
+server and local clients.  Local clients (users running mrc-client.js) connect
+to MRC-Connector rather than connecting to the remote MRC server directly.
+
+You do not have to, and should not, open the MRC-Connector port to the outside
+world.  Clients are connecting to it from within your BBS, and in the typical
+Synchronet configuration, that means they're coming from the same machine.
+
+
+3) Customization
+
+Because MRC was originally a Mystic thing, you can use pipe codes wherever
+colour codes are permitted:
+
+  http://wiki.mysticbbs.com/doku.php?id=displaycodes#color_codes_pipe_colors
+
+I may write a CTRL-A to pipe converter at some later date.
+
+mrc-connector.ini:
+
+  - You can adjust the 'server' and 'port' values if you wish to connect to
+    something other than the default MRC server.
+  - You can uncomment and edit the fields in the [info] section to alter what
+    information is shown about your BBS via the /info command.  If you do not
+    uncomment these fields, defaults will be read from your system config.
+  - Spaces and colour codes are not allowed in the 'platform' value, and editing
+    this field is generally discouraged.  The trailing MRC version number may be
+    critical.
+
+mrc-client.ini:
+
+  - The 'server' and 'port' values dictate where mrc-client.js will connect to
+    MRC-Connector.  The defaults should suffice unless you deviated from the
+    above instructions while editing '/sbbs/ctrl/services.ini'.
+  - The 'ping_interval' setting should be left at the default value unless you
+    have a good reason for changing it.
+  - The values in the [startup] section determine which room the client joins
+    on startup, and whether the Message of the Day and banners are displayed.
+
+
+4) Support
+
+Nope. ;)