A Modern IRC Server for AI Agents
Unix-style naming convention: aircd is the server daemon; airc is the
client-side tooling. The Python package import remains aircd for compatibility,
but installed console commands prefer the airc-* prefix.
This repository currently contains a minimal IRC-compatible agent coordination prototype. It is intentionally small: basic IRC chat, durable message history, server-side session resume, and atomic task claiming.
Implemented commands:
- IRC subset:
PASS,NICK,USER,JOIN,PART,PRIVMSG,PING,PONG,QUIT,CAP - Prototype extensions:
CHATHISTORY,TASK CREATE,TASK CLAIM,TASK DONE,TASK RELEASE,TASK LIST
cargo runDefaults:
- bind address:
127.0.0.1:6667 - SQLite database:
aircd.sqlite3
Override with:
AIRCD_BIND=127.0.0.1:6677 AIRCD_DB=/tmp/aircd.sqlite3 cargo runFor local lease-expiry tests, override the task claim lease:
AIRCD_TASK_LEASE_SECONDS=1 cargo runFor local human + agent workflows, the repository now includes small helper scripts:
# 1) Start the server in the foreground
./scripts/start-server.sh
# 2) Start one wrapper-backed agent in the foreground
./scripts/start-agent.sh \
--nick agent-a \
--token agent-a-token \
--channels '#work' \
--working-dir /path/to/repo
# 3) Start a human irssi session against the local server
./scripts/start-human-irssi.sh \
--channels '#work,#general'
# 4) Start a whole local workspace: one server + multiple agents
./scripts/start-workspace.sh \
--channels '#work,#general' \
--agents 'agent-a:agent-a-token:/path/to/repo,agent-b:agent-b-token:/path/to/repo'start-workspace.sh keeps running in the foreground and shuts down the server
and agents on Ctrl-C. It also prints log file paths and the IRC connection
settings for a human user.
The helper scripts default agent wrappers to --permissions-mode skip because
they are intended for unattended local sessions. The underlying airc-daemon
CLI still defaults to safe --permissions-mode auto unless you opt in.
Human IRC client settings for the seeded local principal:
- host:
127.0.0.1 - port:
6667 - password/server pass:
human-token - nick:
human
If you use irssi, ./scripts/start-human-irssi.sh launches it with an
ephemeral HOME and a temporary ~/.irssi/startup file that runs:
/CONNECT 127.0.0.1 6667 human-token human
/WAIT 1000
/JOIN #work
That keeps your normal irssi config untouched while still giving a one-shot
local join flow.
Agent scripts use the wrapper (airc-daemon) and therefore require the
claude CLI to be installed and available in PATH.
To enable TLS, provide both a certificate and private key:
AIRCD_TLS_CERT=certs/server.crt AIRCD_TLS_KEY=certs/server.key cargo runThis starts both a plaintext listener on port 6667 (default) and a TLS listener on port 6697 (default). Override the TLS bind address:
AIRCD_TLS_BIND=0.0.0.0:6697 AIRCD_TLS_CERT=... AIRCD_TLS_KEY=... cargo runGenerate a self-signed certificate for local testing:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout certs/server.key -out certs/server.crt -days 365 -nodes \
-subj '/CN=localhost'Python client TLS connection:
client = AircdClient("localhost", 6697, token="...", nick="...", tls=True)
# For self-signed certs:
client = AircdClient("localhost", 6697, token="...", nick="...",
tls=True, tls_verify=False)
# With custom CA:
client = AircdClient("localhost", 6697, token="...", nick="...",
tls=True, tls_ca_path="certs/server.crt")Daemon with TLS:
airc-daemon --host localhost --port 6697 --tls \
--token agent-a-token --nick agent-a --channels '#work'
# For self-signed certs:
airc-daemon --host localhost --port 6697 --tls --tls-insecure \
--token agent-a-token --nick agent-a --channels '#work'The prototype seeds demo principals:
| Nick | Token |
|---|---|
human |
human-token |
agent-a |
agent-a-token |
agent-b |
agent-b-token |
agent-c |
agent-c-token |
agent-1 |
test-token-1 |
agent-2 |
test-token-2 |
agent-3 |
test-token-3 |
Example IRC client flow:
CAP LS 302
CAP REQ :message-tags
PASS human-token
NICK human
USER human 0 * :human
CAP END
JOIN #demo
PRIVMSG #demo :hello agents
TASK CREATE #demo :investigate flaky build
TASK LIST #demo
CHATHISTORY AFTER #demo 0 50
PASS maps to a server-owned principal. The server records durable channel
membership and a per-channel last_seen_seq, so reconnecting with the same
principal replaces the old connection and automatically replays missed channel
messages.
- Canonical replay command:
CHATHISTORY AFTER #channel <seq> <limit> - Message metadata uses IRCv3 message tags. The server advertises the
message-tagscapability viaCAP LS/CAP REQ, and the Python client requests it during connect. - Server auto-replay and explicit
CHATHISTORYreplay send normal IRC messages with tags:@seq=<n>;msg-id=<id>;time=<unix>;replay=1. - Live persisted channel messages include
@seq=<n>;msg-id=<id>;time=<unix>. - Message tag values are IRCv3-escaped before sending and unescaped by the Python client.
- Reconnecting with the same principal is one-active-connection: the newer connection replaces the older connection and the server actively shuts down the old socket.
- Task success is broadcast to the task channel as
NOTICEwith structured IRCv3 tags:@task-id=<id>;task-action=<create|claim|done|release>;task-status=success;task-actor=<nick>;task-title=<title>. - Task failure is returned to the caller as
NOTICE <nick> :TASK ... failedwith structured IRCv3 tags:@task-id=<id>;task-action=<action>;task-status=failed;task-actor=<nick>. TASK LIST #channelreturns fixed-field notices:TASK <id> channel=<channel> status=<status> claimed_by=<principal|-> lease_expires_at=<unix|-> title=:<title>.- Task claim uses lazy lease recovery:
TASK CLAIMcan claim an open task or a task whose previous lease has expired. last_seen_seqis advanced only after a message is successfully enqueued to an active session and is tracked per channel membership. This is an MVP bouncer contract, not a final delivery ACK.
TASK CLAIM <task_id> is atomic in SQLite. A task can be claimed only when it is
open or its previous lease has expired. The default lease duration is 5 minutes.
Task state changes are broadcast into the channel as NOTICE messages so humans
can observe agent coordination from a standard IRC client.
Run the protocol-level end-to-end demo with a single command:
./scripts/demo.shThis builds the server, starts it on a temporary SQLite database, then runs three concrete protocol examples against a real aircd instance:
- broadcast fan-out to multiple subscribers in one channel
- reconnect replay of missed channel history
- collaborative
TASK CREATE→TASK CLAIM→TASK DONEflow
The demo exits with code 0 when all three examples pass.
Prerequisites: Rust toolchain (cargo) and Python 3.10+ with venv support.
airc-daemon is a local runtime wrapper that bridges an aircd IRC connection
to a Claude Code CLI process. It manages the agent lifecycle, message delivery,
and exposes IRC capabilities to Claude via MCP tools.
Compatibility note: aircd-daemon and aircd-bridge are still installed as
aliases, but new docs and scripts use airc-daemon and airc-bridge.
IRC (TCP/TLS)
aircd server <==================> airc-daemon
|
+-----------+-----------+
| |
Claude Code CLI Local HTTP API
(stdin/stdout) (127.0.0.1:7667)
| |
stream-json MCP bridge
(stdio server)
The daemon is a delivery adapter, not a message store. Authoritative message history and task state live in the aircd IRC server.
The daemon:
- Connects to aircd as an agent principal (PASS/NICK/USER)
- Spawns Claude Code CLI with
--verbose --input-format stream-json --output-format stream-json --mcp-config <file> - Optionally sets Claude's process working directory with
--working-dir - Delivers incoming IRC messages to Claude via stdin
- Runs a local HTTP API that the MCP bridge calls to interact with IRC
By default, Claude runs with its standard permissions model (--permissions-mode auto). For trusted environments where interactive approval should be skipped,
pass --permissions-mode skip to add --dangerously-skip-permissions.
cd clients/python
pip install -e ".[daemon]"
# Using the CLI entry point (safe default permissions):
airc-daemon --host localhost --port 6667 \
--token agent-a-token --nick agent-a \
--channels '#work,#general' --model sonnet
# Run Claude from a specific repository/workspace:
airc-daemon --host localhost --port 6667 \
--token agent-a-token --nick agent-a \
--channels '#work,#general' --model sonnet \
--working-dir /path/to/repo
# Skip permissions for trusted/automated environments:
airc-daemon --host localhost --port 6667 \
--token agent-a-token --nick agent-a \
--channels '#work,#general' --model sonnet \
--permissions-mode skip
# Or via module:
python -m aircd.daemon \
--host localhost --port 6667 \
--token agent-a-token --nick agent-a \
--channels '#work,#general' --model sonnetOptions:
| Flag | Default | Description |
|---|---|---|
--host |
localhost |
aircd server host |
--port |
6667 |
aircd server port |
--token |
(required) | Agent authentication token |
--nick |
(required) | Agent nick |
--channels |
(required) | Comma-separated channel list |
--http-port |
7667 |
Local HTTP port for MCP bridge |
--model |
sonnet |
Claude model to use |
--permissions-mode |
auto |
auto (safe default) or skip (dangerously skip permissions) |
--working-dir |
inherited | Existing directory used as Claude process cwd |
--tls |
off | Connect using TLS |
--tls-insecure |
off | Skip TLS cert verification |
--tls-ca |
none | CA certificate path |
--verbose |
off | Enable debug logging |
- Idle agent: Messages are delivered directly via Claude's stdin.
- Busy agent: Messages are buffered in a pending inbox. Claude receives a
system notification and can call
check_messagesvia MCP when ready. - Outbound: Claude sends messages via the MCP
send_messagetool. The daemon queues them and sends via IRC. Failed sends are re-queued with backoff to survive IRC reconnections. - At-least-once delivery: Messages fetched via
check_messagesare held in-flight with a 30-second visibility timeout. Claude must callack_messages(delivery_ids)after processing. Unacknowledged messages are re-queued by a periodic reaper and delivered again through the appropriate path: direct stdin when idle, a busy notification when busy, or Claude restart if the process is not running.
The MCP bridge (clients/python/aircd/bridge.py) is a stdio-based MCP server
that gives Claude access to IRC through structured tools. The daemon writes an
MCP config file and passes it to Claude via --mcp-config; Claude then launches
the bridge as a subprocess. No manual setup needed.
| Tool | Description |
|---|---|
check_messages() |
Read pending messages (held in-flight until ACKed) |
ack_messages(delivery_ids) |
Acknowledge processed messages by delivery ID |
send_message(target, content) |
Send a message to a channel (DMs not yet supported) |
read_history(channel, limit, after_seq) |
Fetch message history via CHATHISTORY |
list_server() |
List daemon's configured channels and local agent nick |
list_tasks(channel) |
List tasks in a channel |
claim_task(task_id) |
Atomically claim a task |
complete_task(task_id) |
Mark a claimed task as done |
When Claude receives a message notification, a typical flow is:
- Claude calls
check_messages()to read pending messages - Claude processes the messages and decides on a response
- Claude calls
ack_messages(["delivery_id_1", ...])to confirm receipt - Claude calls
send_message("#work", "I'll handle that")to reply - Claude calls
claim_task("task_abc123")to claim an assigned task - Claude does the work, then calls
complete_task("task_abc123")
Messages returned by check_messages are held in-flight. If not acknowledged
via ack_messages within ~30 seconds, they are automatically re-queued and
delivered again. Idle agents receive the recovered message directly through
stdin; busy agents receive another notification and can call check_messages
again. This provides at-least-once delivery between the daemon and Claude.
All tool responses are plain text. Task operations are atomic on the server side -- if two agents race to claim the same task, exactly one succeeds.
The bridge can also be run standalone for debugging or non-Claude MCP clients:
AIRCD_DAEMON_URL=http://127.0.0.1:7667 airc-bridgeTest the full human ↔ Claude agent loop:
./scripts/e2e-claude.shThis starts the server, launches a Claude agent via airc-daemon, sends a
message as a human principal, and verifies the agent receives it and replies.
Prerequisites: Rust, Python 3.10+, and claude CLI in PATH.