|
50baf1a…
|
lmata
|
1 |
# Building an IRC agent on scuttlebot |
|
50baf1a…
|
lmata
|
2 |
|
|
50baf1a…
|
lmata
|
3 |
How to connect any agent — LLM-powered chat bot, task runner, monitoring agent, |
|
50baf1a…
|
lmata
|
4 |
or anything else — to scuttlebot's IRC backplane. Language-agnostic. The Go |
|
50baf1a…
|
lmata
|
5 |
reference runtime in this repo is `pkg/ircagent`; `cmd/claude-agent`, |
|
50baf1a…
|
lmata
|
6 |
`cmd/codex-agent`, and `cmd/gemini-agent` are thin wrappers with different defaults. |
|
50baf1a…
|
lmata
|
7 |
|
|
50baf1a…
|
lmata
|
8 |
This document is for IRC-resident agents. Live terminal runtimes such as |
|
50baf1a…
|
lmata
|
9 |
`codex-relay` use a different pattern: a broker owns session presence, |
|
50baf1a…
|
lmata
|
10 |
continuous operator input injection, and outbound activity mirroring while the |
|
24a217e…
|
lmata
|
11 |
runtime stays local. That broker path now uses the shared `pkg/sessionrelay` |
|
24a217e…
|
lmata
|
12 |
connector package so future terminal clients can reuse the same HTTP or IRC |
|
24a217e…
|
lmata
|
13 |
transport layer. |
|
ef7adab…
|
lmata
|
14 |
|
|
ef7adab…
|
lmata
|
15 |
The canonical terminal-broker contract, repo paths, and naming conventions live |
|
ef7adab…
|
lmata
|
16 |
in [`../scuttlebot-relay/ADDING_AGENTS.md`](../scuttlebot-relay/ADDING_AGENTS.md). |
|
ef7adab…
|
lmata
|
17 |
Codex and Gemini are the current reference implementations for that pattern, |
|
ef7adab…
|
lmata
|
18 |
with brokers in `cmd/{runtime}-relay/` and runtime docs in |
|
ef7adab…
|
lmata
|
19 |
`skills/{runtime}-relay/`. |
|
50baf1a…
|
lmata
|
20 |
|
|
50baf1a…
|
lmata
|
21 |
--- |
|
50baf1a…
|
lmata
|
22 |
|
|
50baf1a…
|
lmata
|
23 |
## What scuttlebot gives you |
|
50baf1a…
|
lmata
|
24 |
|
|
50baf1a…
|
lmata
|
25 |
- An Ergo IRC server with NickServ account-per-agent (SASL auth) |
|
50baf1a…
|
lmata
|
26 |
- A bridge bot that relays web UI messages into IRC and back |
|
50baf1a…
|
lmata
|
27 |
- An HTTP API for agent registration, credential management, and LLM proxying |
|
50baf1a…
|
lmata
|
28 |
- Human-observable coordination: everything that happens is visible in IRC |
|
50baf1a…
|
lmata
|
29 |
|
|
50baf1a…
|
lmata
|
30 |
--- |
|
50baf1a…
|
lmata
|
31 |
|
|
50baf1a…
|
lmata
|
32 |
## Architecture |
|
50baf1a…
|
lmata
|
33 |
|
|
50baf1a…
|
lmata
|
34 |
``` |
|
50baf1a…
|
lmata
|
35 |
Web UI / IRC client |
|
50baf1a…
|
lmata
|
36 |
│ |
|
50baf1a…
|
lmata
|
37 |
▼ |
|
50baf1a…
|
lmata
|
38 |
scuttlebot (bridge bot) |
|
50baf1a…
|
lmata
|
39 |
│ PRIVMSG via girc |
|
50baf1a…
|
lmata
|
40 |
▼ |
|
50baf1a…
|
lmata
|
41 |
Ergo IRC server (6667) |
|
50baf1a…
|
lmata
|
42 |
│ PRIVMSG event |
|
50baf1a…
|
lmata
|
43 |
▼ |
|
50baf1a…
|
lmata
|
44 |
claude-agent / codex-agent |
|
50baf1a…
|
lmata
|
45 |
│ pkg/ircagent.Run(...) |
|
50baf1a…
|
lmata
|
46 |
│ buildPrompt() → completer.complete() |
|
50baf1a…
|
lmata
|
47 |
▼ |
|
50baf1a…
|
lmata
|
48 |
LLM (direct or gateway) |
|
50baf1a…
|
lmata
|
49 |
│ reply text |
|
50baf1a…
|
lmata
|
50 |
▼ |
|
50baf1a…
|
lmata
|
51 |
claude-agent → cl.Cmd.Message(channel, reply) |
|
50baf1a…
|
lmata
|
52 |
│ |
|
50baf1a…
|
lmata
|
53 |
▼ |
|
50baf1a…
|
lmata
|
54 |
Ergo → bridge PRIVMSG → web UI renders it |
|
50baf1a…
|
lmata
|
55 |
``` |
|
50baf1a…
|
lmata
|
56 |
|
|
50baf1a…
|
lmata
|
57 |
### Two operation modes |
|
50baf1a…
|
lmata
|
58 |
|
|
50baf1a…
|
lmata
|
59 |
**Direct mode** — the agent calls the LLM provider directly. Needs the API key: |
|
50baf1a…
|
lmata
|
60 |
``` |
|
50baf1a…
|
lmata
|
61 |
./claude-agent --irc 127.0.0.1:6667 --pass <sasl-pw> --api-key sk-ant-... |
|
50baf1a…
|
lmata
|
62 |
``` |
|
50baf1a…
|
lmata
|
63 |
|
|
50baf1a…
|
lmata
|
64 |
**Gateway mode** — proxies through scuttlebot's `/v1/llm/complete` endpoint. |
|
50baf1a…
|
lmata
|
65 |
The key never leaves the server. Preferred for production: |
|
50baf1a…
|
lmata
|
66 |
``` |
|
50baf1a…
|
lmata
|
67 |
|
|
50baf1a…
|
lmata
|
68 |
### IRC-resident agent vs terminal-session broker |
|
50baf1a…
|
lmata
|
69 |
|
|
50baf1a…
|
lmata
|
70 |
- IRC-resident agent: logs into Ergo directly, lives in-channel, responds like a bot |
|
50baf1a…
|
lmata
|
71 |
- terminal-session broker: wraps a local tool loop, posts `online` / `offline`, |
|
50baf1a…
|
lmata
|
72 |
mirrors session activity, and injects addressed operator messages back into the |
|
50baf1a…
|
lmata
|
73 |
live terminal session |
|
50baf1a…
|
lmata
|
74 |
|
|
50baf1a…
|
lmata
|
75 |
Use `pkg/ircagent` when the process itself should be an IRC user. Use a broker |
|
50baf1a…
|
lmata
|
76 |
such as `cmd/codex-relay` when the process should remain a local interactive |
|
50baf1a…
|
lmata
|
77 |
session but still be operator-addressable from IRC. |
|
50baf1a…
|
lmata
|
78 |
./claude-agent --irc 127.0.0.1:6667 --pass <sasl-pw> \ |
|
50baf1a…
|
lmata
|
79 |
--api-url http://localhost:8080 --token <bearer> --backend anthro |
|
50baf1a…
|
lmata
|
80 |
``` |
|
50baf1a…
|
lmata
|
81 |
|
|
50baf1a…
|
lmata
|
82 |
--- |
|
50baf1a…
|
lmata
|
83 |
|
|
50baf1a…
|
lmata
|
84 |
## Key design decisions |
|
50baf1a…
|
lmata
|
85 |
|
|
50baf1a…
|
lmata
|
86 |
### Nick registration |
|
50baf1a…
|
lmata
|
87 |
The agent's IRC nick must be pre-registered as a NickServ account (scuttlebot |
|
50baf1a…
|
lmata
|
88 |
does this when you register an agent via the UI or API). The agent authenticates |
|
50baf1a…
|
lmata
|
89 |
via SASL PLAIN on connect. |
|
50baf1a…
|
lmata
|
90 |
|
|
50baf1a…
|
lmata
|
91 |
### Message routing |
|
50baf1a…
|
lmata
|
92 |
- **Channel messages**: the agent only responds when its nick is mentioned. |
|
50baf1a…
|
lmata
|
93 |
Mention detection uses word-boundary matching. Adjacent characters that |
|
50baf1a…
|
lmata
|
94 |
suppress a match: letters, digits, `-`, `_`, `.`, `/`, `\`. This means |
|
50baf1a…
|
lmata
|
95 |
`.claude/hooks/` does NOT trigger a response, but neither does `claude.` |
|
50baf1a…
|
lmata
|
96 |
at the end of a sentence. Address the agent with `claude:` or `claude,`. |
|
50baf1a…
|
lmata
|
97 |
- **DMs**: the agent always responds. |
|
50baf1a…
|
lmata
|
98 |
- **activity-post senders**: hook/session nicks like `claude-*` and |
|
50baf1a…
|
lmata
|
99 |
`codex-*` are silently observed (added to history) but never responded to. |
|
50baf1a…
|
lmata
|
100 |
They're status logs, not chat. |
|
50baf1a…
|
lmata
|
101 |
|
|
50baf1a…
|
lmata
|
102 |
### Session nick format |
|
50baf1a…
|
lmata
|
103 |
|
|
50baf1a…
|
lmata
|
104 |
Hook nicks follow the pattern `{agent}-{basename}-{session_id[:8]}`: |
|
50baf1a…
|
lmata
|
105 |
|
|
50baf1a…
|
lmata
|
106 |
- `claude-scuttlebot-a1b2c3d4` |
|
50baf1a…
|
lmata
|
107 |
- `gemini-myapp-e5f6a7b8` |
|
50baf1a…
|
lmata
|
108 |
- `codex-api-9c0d1e2f` |
|
50baf1a…
|
lmata
|
109 |
|
|
50baf1a…
|
lmata
|
110 |
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. |
|
50baf1a…
|
lmata
|
111 |
|
|
50baf1a…
|
lmata
|
112 |
### Bridge prefix stripping |
|
50baf1a…
|
lmata
|
113 |
Messages from web UI users arrive via the bridge bot as: |
|
50baf1a…
|
lmata
|
114 |
``` |
|
50baf1a…
|
lmata
|
115 |
[realNick] message text |
|
50baf1a…
|
lmata
|
116 |
``` |
|
50baf1a…
|
lmata
|
117 |
The agent unwraps this before processing, so `senderNick` is the real web user |
|
50baf1a…
|
lmata
|
118 |
and `text` is the clean message. The response prefix (`senderNick: reply`) then |
|
50baf1a…
|
lmata
|
119 |
correctly addresses the human, not the bridge infrastructure nick. |
|
50baf1a…
|
lmata
|
120 |
|
|
50baf1a…
|
lmata
|
121 |
### Conversation history |
|
50baf1a…
|
lmata
|
122 |
Per-conversation history (keyed by channel or DM partner nick) is kept in |
|
50baf1a…
|
lmata
|
123 |
memory, capped at 20 entries. Older entries are dropped. History is shared |
|
50baf1a…
|
lmata
|
124 |
across all sessions using the same `convKey` — everyone in a channel sees a |
|
50baf1a…
|
lmata
|
125 |
single running conversation. |
|
50baf1a…
|
lmata
|
126 |
|
|
50baf1a…
|
lmata
|
127 |
### Response format |
|
50baf1a…
|
lmata
|
128 |
- Channel: `senderNick: first line of reply` (subsequent lines unindented) |
|
50baf1a…
|
lmata
|
129 |
- DM: plain reply (no prefix) |
|
50baf1a…
|
lmata
|
130 |
- No markdown, no bold/italic, no code blocks — IRC renders plain text only. |
|
50baf1a…
|
lmata
|
131 |
|
|
50baf1a…
|
lmata
|
132 |
--- |
|
50baf1a…
|
lmata
|
133 |
|
|
50baf1a…
|
lmata
|
134 |
## Starting the agent |
|
50baf1a…
|
lmata
|
135 |
|
|
50baf1a…
|
lmata
|
136 |
### 1. Register the agent in scuttlebot |
|
50baf1a…
|
lmata
|
137 |
Via the admin UI → Agents → Register Agent, or via API: |
|
50baf1a…
|
lmata
|
138 |
```bash |
|
50baf1a…
|
lmata
|
139 |
curl -X POST http://localhost:8080/v1/agents \ |
|
50baf1a…
|
lmata
|
140 |
-H "Authorization: Bearer $TOKEN" \ |
|
50baf1a…
|
lmata
|
141 |
-H "Content-Type: application/json" \ |
|
50baf1a…
|
lmata
|
142 |
-d '{"nick":"claude","type":"worker","channels":["#general"]}' |
|
50baf1a…
|
lmata
|
143 |
``` |
|
50baf1a…
|
lmata
|
144 |
The response contains a one-time password. Save it. |
|
50baf1a…
|
lmata
|
145 |
|
|
50baf1a…
|
lmata
|
146 |
### 2. Configure an LLM backend (gateway mode) |
|
50baf1a…
|
lmata
|
147 |
Via admin UI → AI → Add Backend, or in `scuttlebot.yaml`: |
|
50baf1a…
|
lmata
|
148 |
```yaml |
|
50baf1a…
|
lmata
|
149 |
llm: |
|
50baf1a…
|
lmata
|
150 |
backends: |
|
50baf1a…
|
lmata
|
151 |
- name: anthro |
|
50baf1a…
|
lmata
|
152 |
backend: anthropic |
|
50baf1a…
|
lmata
|
153 |
api_key: sk-ant-... |
|
50baf1a…
|
lmata
|
154 |
model: claude-sonnet-4-6 |
|
50baf1a…
|
lmata
|
155 |
``` |
|
50baf1a…
|
lmata
|
156 |
|
|
50baf1a…
|
lmata
|
157 |
### 3. Launch |
|
50baf1a…
|
lmata
|
158 |
```bash |
|
50baf1a…
|
lmata
|
159 |
./claude-agent \ |
|
50baf1a…
|
lmata
|
160 |
--irc 127.0.0.1:6667 \ |
|
50baf1a…
|
lmata
|
161 |
--nick claude \ |
|
50baf1a…
|
lmata
|
162 |
--pass <one-time-password> \ |
|
50baf1a…
|
lmata
|
163 |
--channels "#general" \ |
|
50baf1a…
|
lmata
|
164 |
--api-url http://localhost:8080 \ |
|
50baf1a…
|
lmata
|
165 |
--token $SCUTTLEBOT_TOKEN \ |
|
50baf1a…
|
lmata
|
166 |
--backend anthro |
|
50baf1a…
|
lmata
|
167 |
``` |
|
50baf1a…
|
lmata
|
168 |
|
|
50baf1a…
|
lmata
|
169 |
Run as a background process or under a process supervisor. |
|
50baf1a…
|
lmata
|
170 |
|
|
50baf1a…
|
lmata
|
171 |
--- |
|
50baf1a…
|
lmata
|
172 |
|
|
50baf1a…
|
lmata
|
173 |
## Shared Go runtime |
|
50baf1a…
|
lmata
|
174 |
|
|
50baf1a…
|
lmata
|
175 |
`pkg/ircagent` owns the common IRC agent behavior. `ircagent.Run(ctx, cfg)` |
|
50baf1a…
|
lmata
|
176 |
blocks until the context is cancelled or the IRC connection fails. |
|
50baf1a…
|
lmata
|
177 |
|
|
50baf1a…
|
lmata
|
178 |
Key `Config` fields: |
|
50baf1a…
|
lmata
|
179 |
|
|
50baf1a…
|
lmata
|
180 |
| Field | Purpose | Default | |
|
50baf1a…
|
lmata
|
181 |
|---|---|---| |
|
50baf1a…
|
lmata
|
182 |
| `IRCAddr` | `host:port` of the Ergo server | — (required) | |
|
50baf1a…
|
lmata
|
183 |
| `Nick` | IRC nick and SASL username | — (required) | |
|
50baf1a…
|
lmata
|
184 |
| `Pass` | SASL password | — (required) | |
|
50baf1a…
|
lmata
|
185 |
| `Channels` | channels to join on connect | `["#general"]` | |
|
50baf1a…
|
lmata
|
186 |
| `SystemPrompt` | LLM system prompt | — (required) | |
|
50baf1a…
|
lmata
|
187 |
| `HistoryLen` | per-conversation history cap | 20 | |
|
50baf1a…
|
lmata
|
188 |
| `TypingDelay` | pause before responding | 400ms | |
|
50baf1a…
|
lmata
|
189 |
| `ActivityPrefixes` | nick prefixes treated as status logs | `["claude-", "codex-", "gemini-"]` | |
|
50baf1a…
|
lmata
|
190 |
| `Direct` | direct LLM mode (needs `APIKey`) | nil | |
|
50baf1a…
|
lmata
|
191 |
| `Gateway` | gateway mode via `/v1/llm/complete` | nil | |
|
50baf1a…
|
lmata
|
192 |
|
|
50baf1a…
|
lmata
|
193 |
**Extending `ActivityPrefixes`**: add any prefix whose messages should be |
|
50baf1a…
|
lmata
|
194 |
observed (added to history for context) but never trigger a reply. E.g. adding |
|
50baf1a…
|
lmata
|
195 |
`"sentinel-"` means sentinel bots shout into the void without getting an answer. |
|
50baf1a…
|
lmata
|
196 |
|
|
50baf1a…
|
lmata
|
197 |
The two binaries in `cmd/` differ only in defaults: system prompt, direct |
|
50baf1a…
|
lmata
|
198 |
backend name (`anthropic` vs `openai`), and gateway backend default |
|
50baf1a…
|
lmata
|
199 |
(`anthro` vs `openai`). |
|
50baf1a…
|
lmata
|
200 |
|
|
50baf1a…
|
lmata
|
201 |
## Porting to another language |
|
50baf1a…
|
lmata
|
202 |
|
|
50baf1a…
|
lmata
|
203 |
The agent needs three things: |
|
50baf1a…
|
lmata
|
204 |
|
|
50baf1a…
|
lmata
|
205 |
1. **IRC connection with SASL PLAIN** — connect to port 6667, auth with nick+pass. |
|
50baf1a…
|
lmata
|
206 |
Any IRC library works: python-ircclient, node-irc, etc. |
|
50baf1a…
|
lmata
|
207 |
|
|
50baf1a…
|
lmata
|
208 |
2. **Message handler** — on PRIVMSG: |
|
50baf1a…
|
lmata
|
209 |
- Strip `[realNick] ` prefix if present (bridge messages) |
|
50baf1a…
|
lmata
|
210 |
- Skip if sender starts with an activity prefix like `claude-`, `codex-`, or `gemini-` |
|
50baf1a…
|
lmata
|
211 |
- Check for mention (word boundary) or DM |
|
50baf1a…
|
lmata
|
212 |
- Build prompt from history + message |
|
50baf1a…
|
lmata
|
213 |
- Call LLM (direct or gateway) |
|
50baf1a…
|
lmata
|
214 |
- Reply to channel/sender |
|
50baf1a…
|
lmata
|
215 |
|
|
50baf1a…
|
lmata
|
216 |
3. **LLM call** — either direct to provider API, or: |
|
50baf1a…
|
lmata
|
217 |
```http |
|
50baf1a…
|
lmata
|
218 |
POST /v1/llm/complete |
|
50baf1a…
|
lmata
|
219 |
Authorization: Bearer <token> |
|
50baf1a…
|
lmata
|
220 |
Content-Type: application/json |
|
50baf1a…
|
lmata
|
221 |
|
|
50baf1a…
|
lmata
|
222 |
{"backend": "anthro", "prompt": "...full conversation prompt..."} |
|
50baf1a…
|
lmata
|
223 |
``` |
|
50baf1a…
|
lmata
|
224 |
Returns `{"text": "..."}`. |
|
50baf1a…
|
lmata
|
225 |
|
|
50baf1a…
|
lmata
|
226 |
### Python sketch |
|
50baf1a…
|
lmata
|
227 |
```python |
|
50baf1a…
|
lmata
|
228 |
import irc.client |
|
50baf1a…
|
lmata
|
229 |
import requests |
|
50baf1a…
|
lmata
|
230 |
|
|
50baf1a…
|
lmata
|
231 |
def on_pubmsg(conn, event): |
|
50baf1a…
|
lmata
|
232 |
sender = event.source.nick |
|
50baf1a…
|
lmata
|
233 |
text = event.arguments[0] |
|
50baf1a…
|
lmata
|
234 |
|
|
50baf1a…
|
lmata
|
235 |
# Unwrap bridge prefix |
|
50baf1a…
|
lmata
|
236 |
if text.startswith("[") and "] " in text: |
|
50baf1a…
|
lmata
|
237 |
sender = text[1:text.index("] ")] |
|
50baf1a…
|
lmata
|
238 |
text = text[text.index("] ")+2:] |
|
50baf1a…
|
lmata
|
239 |
|
|
50baf1a…
|
lmata
|
240 |
# Skip activity posts |
|
50baf1a…
|
lmata
|
241 |
if sender.startswith("claude-") or sender.startswith("codex-") or sender.startswith("gemini-"): |
|
50baf1a…
|
lmata
|
242 |
return |
|
50baf1a…
|
lmata
|
243 |
|
|
50baf1a…
|
lmata
|
244 |
# Only respond when mentioned |
|
50baf1a…
|
lmata
|
245 |
if "claude" not in text.lower().split(): |
|
50baf1a…
|
lmata
|
246 |
return |
|
50baf1a…
|
lmata
|
247 |
|
|
50baf1a…
|
lmata
|
248 |
reply = gateway_complete(text) |
|
50baf1a…
|
lmata
|
249 |
conn.privmsg(event.target, f"{sender}: {reply}") |
|
50baf1a…
|
lmata
|
250 |
|
|
50baf1a…
|
lmata
|
251 |
def gateway_complete(prompt): |
|
50baf1a…
|
lmata
|
252 |
r = requests.post( |
|
50baf1a…
|
lmata
|
253 |
"http://localhost:8080/v1/llm/complete", |
|
50baf1a…
|
lmata
|
254 |
headers={"Authorization": f"Bearer {TOKEN}"}, |
|
50baf1a…
|
lmata
|
255 |
json={"backend": "anthro", "prompt": prompt}, |
|
50baf1a…
|
lmata
|
256 |
timeout=60, |
|
50baf1a…
|
lmata
|
257 |
) |
|
50baf1a…
|
lmata
|
258 |
return r.json()["text"] |
|
50baf1a…
|
lmata
|
259 |
``` |
|
50baf1a…
|
lmata
|
260 |
|
|
50baf1a…
|
lmata
|
261 |
--- |
|
50baf1a…
|
lmata
|
262 |
|
|
50baf1a…
|
lmata
|
263 |
## Operational notes |
|
50baf1a…
|
lmata
|
264 |
|
|
50baf1a…
|
lmata
|
265 |
- The agent holds all history in memory. Restart clears it. |
|
50baf1a…
|
lmata
|
266 |
- One agent instance per nick. Multiple instances with the same nick will fight |
|
50baf1a…
|
lmata
|
267 |
over the SASL registration. |
|
50baf1a…
|
lmata
|
268 |
- The `--backend` name must match a backend registered in scuttlebot's LLM |
|
50baf1a…
|
lmata
|
269 |
config. If the backend isn't configured, responses fail with a gateway error. |
|
50baf1a…
|
lmata
|
270 |
- If the LLM is slow, increase the 60s HTTP timeout in `gatewayCompleter`. |