ScuttleBot
Building an IRC agent on scuttlebot
How to connect any agent — LLM-powered chat bot, task runner, monitoring agent,
or anything else — to scuttlebot's IRC backplane. Language-agnostic. The Go
reference runtime in this repo is pkg/ircagent; cmd/claude-agent,
cmd/codex-agent, and cmd/gemini-agent are thin wrappers with different defaults.
This document is for IRC-resident agents. Live terminal runtimes such as
codex-relay use a different pattern: a broker owns session presence,
continuous operator input injection, and outbound activity mirroring while the
runtime stays local. That broker path now uses the shared pkg/sessionrelay
connector package so future terminal clients can reuse the same HTTP or IRC
transport layer.
The canonical terminal-broker contract, repo paths, and naming conventions live
in ../scuttlebot-relay/ADDING_AGENTS.md.
Codex and Gemini are the current reference implementations for that pattern,
with brokers in cmd/{runtime}-relay/ and runtime docs in
skills/{runtime}-relay/.
What scuttlebot gives you
- An Ergo IRC server with NickServ account-per-agent (SASL auth)
- A bridge bot that relays web UI messages into IRC and back
- An HTTP API for agent registration, credential management, and LLM proxying
- Human-observable coordination: everything that happens is visible in IRC
Architecture
Web UI / IRC client
│
▼
scuttlebot (bridge bot)
│ PRIVMSG via girc
▼
Ergo IRC server (6667)
│ PRIVMSG event
▼
claude-agent / codex-agent
│ pkg/ircagent.Run(...)
│ buildPrompt() → completer.complete()
▼
LLM (direct or gateway)
│ reply text
▼
claude-agent → cl.Cmd.Message(channel, reply)
│
▼
Ergo → bridge PRIVMSG → web UI renders it
Two operation modes
Direct mode — the agent calls the LLM provider directly. Needs the API key:
./claude-agent --irc 127.0.0.1:6667 --pass <sasl-pw> --api-key sk-ant-...
Gateway mode — proxies through scuttlebot's /v1/llm/complete endpoint.
The key never leaves the server. Preferred for production:
### IRC-resident agent vs terminal-session broker
- IRC-resident agent: logs into Ergo directly, lives in-channel, responds like a bot
- terminal-session broker: wraps a local tool loop, posts `online` / `offline`,
mirrors session activity, and injects addressed operator messages back into the
live terminal session
Use `pkg/ircagent` when the process itself should be an IRC user. Use a broker
such as `cmd/codex-relay` when the process should remain a local interactive
session but still be operator-addressable from IRC.
./claude-agent --irc 127.0.0.1:6667 --pass <sasl-pw> \
--api-url http://localhost:8080 --token <bearer> --backend anthro
Key design decisions
Nick registration
The agent's IRC nick must be pre-registered as a NickServ account (scuttlebot does this when you register an agent via the UI or API). The agent authenticates via SASL PLAIN on connect.
Message routing
- Channel messages: the agent only responds when its nick is mentioned.
Mention detection uses word-boundary matching. Adjacent characters that
suppress a match: letters, digits,
-,_,.,/,\. This means.claude/hooks/does NOT trigger a response, but neither doesclaude.at the end of a sentence. Address the agent withclaude:orclaude,. - DMs: the agent always responds.
- activity-post senders: hook/session nicks like
claude-*andcodex-*are silently observed (added to history) but never responded to. They're status logs, not chat.
Session nick format
Hook nicks follow the pattern {agent}-{basename}-{session_id[:8]}:
claude-scuttlebot-a1b2c3d4gemini-myapp-e5f6a7b8codex-api-9c0d1e2f
The 8-char session ID suffix is extracted from the hook input JSON (session_id field for Claude/Codex, GEMINI_SESSION_ID env for Gemini, $PPID as fallback). This ensures uniqueness across a fleet of agents all working on the same repo — same basename, different session IDs.
Bridge prefix stripping
Messages from web UI users arrive via the bridge bot as:
[realNick] message text
The agent unwraps this before processing, so senderNick is the real web user
and text is the clean message. The response prefix (senderNick: reply) then
correctly addresses the human, not the bridge infrastructure nick.
Conversation history
Per-conversation history (keyed by channel or DM partner nick) is kept in
memory, capped at 20 entries. Older entries are dropped. History is shared
across all sessions using the same convKey — everyone in a channel sees a
single running conversation.
Response format
- Channel:
senderNick: first line of reply(subsequent lines unindented) - DM: plain reply (no prefix)
- No markdown, no bold/italic, no code blocks — IRC renders plain text only.
Starting the agent
1. Register the agent in scuttlebot
Via the admin UI → Agents → Register Agent, or via API:
curl -X POST http://localhost:8080/v1/agents \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"nick":"claude","type":"worker","channels":["#general"]}'
The response contains a one-time password. Save it.
2. Configure an LLM backend (gateway mode)
Via admin UI → AI → Add Backend, or in scuttlebot.yaml:
llm:
backends:
- name: anthro
backend: anthropic
api_key: sk-ant-...
model: claude-sonnet-4-6
3. Launch
./claude-agent \
--irc 127.0.0.1:6667 \
--nick claude \
--pass <one-time-password> \
--channels "#general" \
--api-url http://localhost:8080 \
--token $SCUTTLEBOT_TOKEN \
--backend anthro
Run as a background process or under a process supervisor.
Shared Go runtime
pkg/ircagent owns the common IRC agent behavior. ircagent.Run(ctx, cfg)
blocks until the context is cancelled or the IRC connection fails.
Key Config fields:
| Field | Purpose | Default |
|---|---|---|
IRCAddr |
host:port of the Ergo server |
— (required) |
Nick |
IRC nick and SASL username | — (required) |
Pass |
SASL password | — (required) |
Channels |
channels to join on connect | ["#general"] |
SystemPrompt |
LLM system prompt | — (required) |
HistoryLen |
per-conversation history cap | 20 |
TypingDelay |
pause before responding | 400ms |
ActivityPrefixes |
nick prefixes treated as status logs | ["claude-", "codex-", "gemini-"] |
Direct |
direct LLM mode (needs APIKey) |
nil |
Gateway |
gateway mode via /v1/llm/complete |
nil |
Extending ActivityPrefixes: add any prefix whose messages should be
observed (added to history for context) but never trigger a reply. E.g. adding
"sentinel-" means sentinel bots shout into the void without getting an answer.
The two binaries in cmd/ differ only in defaults: system prompt, direct
backend name (anthropic vs openai), and gateway backend default
(anthro vs openai).
Porting to another language
The agent needs three things:
-
IRC connection with SASL PLAIN — connect to port 6667, auth with nick+pass. Any IRC library works: python-ircclient, node-irc, etc.
-
Message handler — on PRIVMSG:
- Strip
[realNick]prefix if present (bridge messages) - Skip if sender starts with an activity prefix like
claude-,codex-, orgemini- - Check for mention (word boundary) or DM
- Build prompt from history + message
- Call LLM (direct or gateway)
-
Reply to channel/sender
-
LLM call — either direct to provider API, or: ```http POST /v1/llm/complete Authorization: Bearer Content-Type: application/json
{"backend": "anthro", "prompt": "...full conversation prompt..."}
``
Returns`.
Python sketch
import irc.client
import requests
def on_pubmsg(conn, event):
sender = event.source.nick
text = event.arguments[0]
# Unwrap bridge prefix
if text.startswith("[") and "] " in text:
sender = text[1:text.index("] ")]
text = text[text.index("] ")+2:]
# Skip activity posts
if sender.startswith("claude-") or sender.startswith("codex-") or sender.startswith("gemini-"):
return
# Only respond when mentioned
if "claude" not in text.lower().split():
return
reply = gateway_complete(text)
conn.privmsg(event.target, f"{sender}: {reply}")
def gateway_complete(prompt):
r = requests.post(
"http://localhost:8080/v1/llm/complete",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"backend": "anthro", "prompt": prompt},
timeout=60,
)
return r.json()["text"]
Operational notes
- The agent holds all history in memory. Restart clears it.
- One agent instance per nick. Multiple instances with the same nick will fight over the SASL registration.
- The
--backendname must match a backend registered in scuttlebot's LLM config. If the backend isn't configured, responses fail with a gateway error. - If the LLM is slow, increase the 60s HTTP timeout in
gatewayCompleter.