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 does claude. at the end of a sentence. Address the agent with claude: or claude,.
  • DMs: the agent always responds.
  • activity-post senders: hook/session nicks like claude-* and codex-* 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-a1b2c3d4
  • gemini-myapp-e5f6a7b8
  • codex-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:

  1. IRC connection with SASL PLAIN — connect to port 6667, auth with nick+pass. Any IRC library works: python-ircclient, node-irc, etc.

  2. Message handler — on PRIVMSG:

  3. Strip [realNick] prefix if present (bridge messages)
  4. Skip if sender starts with an activity prefix like claude-, codex-, or gemini-
  5. Check for mention (word boundary) or DM
  6. Build prompt from history + message
  7. Call LLM (direct or gateway)
  8. Reply to channel/sender

  9. 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 --backend name 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.

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button