Adding a New Agent Runtime¶
This guide explains how to add a new agent runtime — a coding assistant, automation tool, or any interactive terminal process — to the scuttlebot relay ecosystem.
The relay ecosystem has two shapes. Read the next section to decide which one you need, then follow the corresponding path.
Relay broker vs. IRC-resident agent¶
Use a relay broker when:
- The runtime is an interactive terminal session (Claude Code, Codex, Gemini CLI, etc.)
- Sessions are ephemeral — they start and stop with each coding task
- You want per-session presence (
online/offline) and per-session operator instructions - The runtime exposes a session log, hook points, or a PTY you can wrap
Use an IRC-resident agent when:
- The process should run indefinitely (a moderator, an event router, a summarizer)
- Presence and identity are permanent, not per-session
- You are building a new system bot in the style of
oracle,warden, orherald
For IRC-resident agents, use pkg/ircagent/ as your foundation and follow the system bot pattern in internal/bots/. This guide focuses on the relay broker pattern.
Canonical repo layout¶
Every terminal broker follows this layout:
cmd/{runtime}-relay/
main.go broker entrypoint
skills/{runtime}-relay/
install.md human install primer
FLEET.md rollout and operations guide
hooks/
README.md runtime-specific hook contract
scuttlebot-check.sh pre-action hook (check IRC for instructions)
scuttlebot-post.sh post-action hook (post tool activity to IRC)
scripts/
install-{runtime}-relay.sh tracked installer
pkg/sessionrelay/ shared transport (do not copy; import)
Files installed into ~/.{runtime}/, ~/.local/bin/, or ~/.config/ are copies. The repo is the source of truth.
Step-by-step: implementing the broker¶
1. Start from pkg/sessionrelay¶
pkg/sessionrelay provides the Connector interface and two implementations:
type Connector interface {
Connect(ctx context.Context) error
Post(ctx context.Context, text string) error
MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
Touch(ctx context.Context) error
Close(ctx context.Context) error
}
Instantiate with:
conn, err := sessionrelay.New(sessionrelay.Config{
Transport: sessionrelay.TransportIRC, // or TransportHTTP
URL: cfg.URL,
Token: cfg.Token,
Channel: cfg.Channel,
Nick: cfg.Nick,
IRC: sessionrelay.IRCConfig{
Addr: cfg.IRCAddr,
Pass: cfg.IRCPass,
AgentType: "worker",
DeleteOnClose: cfg.IRCDeleteOnClose,
},
})
TransportHTTP routes all posts through the bridge bot (POST /v1/channels/{ch}/messages). TransportIRC self-registers as an agent and connects directly to Ergo via SASL — the broker appears as its own IRC nick.
2. Define your config struct¶
type config struct {
// Required
URL string
Token string
Channel string
Nick string
// Transport
Transport sessionrelay.Transport
IRCAddr string
IRCPass string
IRCDeleteOnClose bool
// Tuning
PollInterval time.Duration
HeartbeatInterval time.Duration
InterruptOnMessage bool
HooksEnabled bool
// Runtime-specific
RuntimeBin string
Args []string
TargetCWD string
}
3. Implement loadConfig¶
Read from environment variables, then from a shared env file (~/.config/scuttlebot-relay.env), then apply defaults:
func loadConfig() config {
cfgFile := envOr("SCUTTLEBOT_CONFIG_FILE",
filepath.Join(os.Getenv("HOME"), ".config/scuttlebot-relay.env"))
loadEnvFile(cfgFile)
transport := sessionrelay.Transport(envOr("SCUTTLEBOT_TRANSPORT", "irc"))
return config{
URL: envOr("SCUTTLEBOT_URL", "http://localhost:8080"),
Token: os.Getenv("SCUTTLEBOT_TOKEN"),
Channel: envOr("SCUTTLEBOT_CHANNEL", "general"),
Nick: os.Getenv("SCUTTLEBOT_NICK"), // derived below if empty
Transport: transport,
IRCAddr: envOr("SCUTTLEBOT_IRC_ADDR", "127.0.0.1:6667"),
IRCPass: os.Getenv("SCUTTLEBOT_IRC_PASS"),
IRCDeleteOnClose: os.Getenv("SCUTTLEBOT_IRC_DELETE_ON_CLOSE") == "1",
HooksEnabled: envOr("SCUTTLEBOT_HOOKS_ENABLED", "1") != "0",
InterruptOnMessage: os.Getenv("SCUTTLEBOT_INTERRUPT_ON_MESSAGE") == "1",
PollInterval: parseDuration("SCUTTLEBOT_POLL_INTERVAL", 2*time.Second),
HeartbeatInterval: parseDuration("SCUTTLEBOT_PRESENCE_HEARTBEAT", 60*time.Second),
}
}
4. Derive the session nick¶
func deriveNick(runtime, cwd string) string {
// Sanitize the repo directory name.
base := sanitize(filepath.Base(cwd))
// Stable 8-char hex from pid + ppid + current time.
h := crc32.NewIEEE()
fmt.Fprintf(h, "%d%d%d", os.Getpid(), os.Getppid(), time.Now().UnixNano())
suffix := fmt.Sprintf("%08x", h.Sum32())
return fmt.Sprintf("%s-%s-%s", runtime, base, suffix[:8])
}
func sanitize(s string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
return re.ReplaceAllString(s, "-")
}
Nick format: {runtime}-{basename}-{session_id[:8]}
For runtimes that expose a stable session UUID (like Claude Code), prefer that over the PID-based suffix.
5. Implement run¶
The top-level run function wires everything together:
func run(ctx context.Context, cfg config) error {
conn, err := sessionrelay.New(sessionrelay.Config{ /* ... */ })
if err != nil {
return fmt.Errorf("relay: connect: %w", err)
}
if err := conn.Connect(ctx); err != nil {
// Soft-fail: log, then start the runtime anyway.
log.Printf("relay: scuttlebot unreachable, running without relay: %v", err)
return runRuntimeDirect(ctx, cfg)
}
defer conn.Close(ctx)
// Announce presence.
_ = conn.Post(ctx, cfg.Nick+" online")
// Start the runtime under a PTY.
ptmx, cmd, err := startRuntime(cfg)
if err != nil {
return fmt.Errorf("relay: start runtime: %w", err)
}
var wg sync.WaitGroup
// Mirror runtime output → IRC.
wg.Add(1)
go func() {
defer wg.Done()
mirrorSessionLoop(ctx, cfg, conn, sessionDir(cfg))
}()
// Poll IRC → inject into runtime.
wg.Add(1)
go func() {
defer wg.Done()
relayInputLoop(ctx, cfg, conn, ptmx)
}()
// Wait for runtime to exit.
_ = cmd.Wait()
_ = conn.Post(ctx, cfg.Nick+" offline")
wg.Wait()
return nil
}
6. Implement mirrorSessionLoop¶
This goroutine tails the runtime's session JSONL log and posts summarized activity to IRC.
func mirrorSessionLoop(ctx context.Context, cfg config, conn sessionrelay.Connector, dir string) {
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
var lastPos int64
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
file := latestSessionFile(dir)
if file == "" {
continue
}
lines, pos := readNewLines(file, lastPos)
lastPos = pos
for _, line := range lines {
if msg := extractActivityLine(line); msg != "" {
_ = conn.Post(ctx, msg)
}
}
}
}
}
7. Implement relayInputLoop¶
This goroutine polls the IRC channel for operator messages and injects them into the runtime.
func relayInputLoop(ctx context.Context, cfg config, conn sessionrelay.Connector, ptmx *os.File) {
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
var lastCheck time.Time
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
msgs, err := conn.MessagesSince(ctx, lastCheck)
if err != nil {
continue
}
lastCheck = time.Now()
for _, m := range filterInbound(msgs, cfg.Nick) {
injectInstruction(ptmx, m.Text)
}
}
}
}
Session file discovery¶
Each runtime stores its session data in a different location:
| Runtime | Session log location |
|---|---|
| Claude Code | ~/.claude/projects/{cwd-hash}/ — JSONL files named by session UUID |
| Codex | ~/.codex/sessions/{session-id}.jsonl |
| Gemini CLI | ~/.gemini/sessions/{session-id}.jsonl |
To find the latest session file:
func latestSessionFile(dir string) string {
entries, _ := os.ReadDir(dir)
var newest os.DirEntry
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
if newest == nil {
newest = e
continue
}
ni, _ := newest.Info()
ei, _ := e.Info()
if ei.ModTime().After(ni.ModTime()) {
newest = e
}
}
if newest == nil {
return ""
}
return filepath.Join(dir, newest.Name())
}
For Claude Code specifically, the project directory is derived from the working directory path — see cmd/claude-relay/main.go for the exact hashing logic.
Message parsing — Claude Code JSONL format¶
Each line in a Claude Code session file is a JSON object. The fields you care about:
{
"type": "assistant",
"sessionId": "550e8400-...",
"cwd": "/Users/alice/repos/myproject",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"name": "Bash",
"input": { "command": "go test ./..." }
}
]
}
}
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"content": [{ "type": "text", "text": "ok github.com/..." }]
}
]
}
}
Extracting activity lines:
func extractActivityLine(jsonLine string) string {
var entry claudeSessionEntry
if err := json.Unmarshal([]byte(jsonLine), &entry); err != nil {
return ""
}
if entry.Type != "assistant" {
return ""
}
for _, block := range entry.Message.Content {
switch block.Type {
case "tool_use":
return summarizeToolUse(block.Name, block.Input)
case "text":
if block.Text != "" {
return truncate(block.Text, 360)
}
}
}
return ""
}
For other runtimes, identify the equivalent fields in their session format. Codex and Gemini use similar but not identical schemas — read their session files and map accordingly.
Secret scrubbing: Before posting any line to IRC, run it through a scrubber:
var (
secretHexPattern = regexp.MustCompile(`\b[a-f0-9]{32,}\b`)
secretKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+\b`)
bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._:-]+)`)
assignTokenPattern = regexp.MustCompile(`(?i)\b([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSPHRASE)[A-Z0-9_]*=)([^ \t"'\x60]+)`)
)
func scrubSecrets(s string) string {
s = secretHexPattern.ReplaceAllString(s, "[redacted]")
s = secretKeyPattern.ReplaceAllString(s, "[redacted]")
s = bearerPattern.ReplaceAllStringFunc(s, func(m string) string {
parts := bearerPattern.FindStringSubmatch(m)
return parts[1] + "[redacted]"
})
s = assignTokenPattern.ReplaceAllString(s, "${1}[redacted]")
return s
}
Filtering rules for inbound messages¶
Not every message in the channel is meant for this session. The filter must accept only messages that are all of the following:
- Newer than the last check — track a
lastCheck time.Timeper session key (see below) - Not from this session's own nick — reject self-messages
- Not from a known service bot — reject:
bridge,oracle,sentinel,steward,scribe,warden,snitch,herald,scroll,systembot,auditbot - Not from an agent status nick — reject nicks with prefixes
claude-,codex-,gemini- - Explicitly mentioning this session nick — the message text must contain the nick as a word boundary match, not just as a substring
var serviceBots = map[string]struct{}{
"bridge": {}, "oracle": {}, "sentinel": {}, "steward": {},
"scribe": {}, "warden": {}, "snitch": {}, "herald": {},
"scroll": {}, "systembot": {}, "auditbot": {},
}
var agentPrefixes = []string{"claude-", "codex-", "gemini-"}
func filterInbound(msgs []sessionrelay.Message, selfNick string) []sessionrelay.Message {
var out []sessionrelay.Message
mentionRe := regexp.MustCompile(
`(^|[^[:alnum:]_./\\-])` + regexp.QuoteMeta(selfNick) + `($|[^[:alnum:]_./\\-])`,
)
for _, m := range msgs {
if m.Nick == selfNick {
continue
}
if _, ok := serviceBots[m.Nick]; ok {
continue
}
isAgentNick := false
for _, p := range agentPrefixes {
if strings.HasPrefix(m.Nick, p) {
isAgentNick = true
break
}
}
if isAgentNick {
continue
}
if !mentionRe.MatchString(m.Text) {
continue
}
out = append(out, m)
}
return out
}
Why these rules matter:
- Service bots post frequently (scribe, systembot, auditbot log every event). Letting those through would create feedback loops.
- Agent nicks with runtime prefixes are other sessions' activity mirrors. They are ambient background, not operator instructions.
- Word-boundary mention matching prevents
claude-myrepo-abc12345from triggering on a message that just contains the wordclaude.
State scoping: Do not use a single global timestamp file. Track lastCheck by a key derived from channel + nick + cwd. This prevents parallel sessions in the same channel from consuming each other's instructions:
func stateKey(channel, nick, cwd string) string {
h := fmt.Sprintf("%s|%s|%s", channel, nick, cwd)
sum := crc32.ChecksumIEEE([]byte(h))
return fmt.Sprintf("%08x", sum)
}
The environment contract¶
All relay brokers use the same set of environment variables. Read from the shared env file first, then override from the process environment.
Required:
| Variable | Purpose |
|---|---|
SCUTTLEBOT_URL |
Base URL of the scuttlebot HTTP API (e.g. https://scuttlebot.example.com) |
SCUTTLEBOT_TOKEN |
Bearer token for API auth |
SCUTTLEBOT_CHANNEL |
Target IRC channel (with or without #) |
Common optional:
| Variable | Default | Purpose |
|---|---|---|
SCUTTLEBOT_TRANSPORT |
irc |
http (bridge path) or irc (direct SASL) |
SCUTTLEBOT_NICK |
derived | Override the session nick |
SCUTTLEBOT_SESSION_ID |
derived | Stable session ID for nick derivation |
SCUTTLEBOT_IRC_ADDR |
127.0.0.1:6667 |
Ergo IRC address |
SCUTTLEBOT_IRC_PASS |
— | IRC password (if different from API token) |
SCUTTLEBOT_IRC_DELETE_ON_CLOSE |
0 |
Delete the IRC account when the session ends |
SCUTTLEBOT_HOOKS_ENABLED |
1 |
Set to 0 to disable all IRC integration |
SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
0 |
Send SIGINT to runtime when operator message arrives |
SCUTTLEBOT_POLL_INTERVAL |
2s |
How often to poll for new IRC messages |
SCUTTLEBOT_PRESENCE_HEARTBEAT |
60s |
HTTP presence touch interval; 0 to disable |
SCUTTLEBOT_CONFIG_FILE |
~/.config/scuttlebot-relay.env |
Path to the shared env file |
SCUTTLEBOT_ACTIVITY_VIA_BROKER |
0 |
Set to 1 when the broker owns activity posts (disables hook-based posting) |
Do not hardcode tokens. The shared env file (~/.config/scuttlebot-relay.env) is the right place for SCUTTLEBOT_TOKEN. Never commit it.
Writing the installer script¶
The installer script lives at skills/{runtime}-relay/scripts/install-{runtime}-relay.sh. It:
- Writes the shared env file (
~/.config/scuttlebot-relay.env) - Copies hook scripts to the runtime's hook directory
- Registers hooks in the runtime's settings JSON
- Copies (or builds) the relay launcher to
~/.local/bin/{runtime}-relay
Key conventions:
- Accept
--url,--token,--channelflags - Fall back to
SCUTTLEBOT_URL,SCUTTLEBOT_TOKEN,SCUTTLEBOT_CHANNELenv vars - Default config file to
~/.config/scuttlebot-relay.env - Default hooks dir to
~/.{runtime}/hooks/ - Default bin dir to
~/.local/bin/ - Print a clear summary of what was written
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
HOOKS_DIR="${RUNTIME_HOOKS_DIR:-$HOME/.{runtime}/hooks}"
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
# ... flag parsing ...
mkdir -p "$(dirname "$CONFIG_FILE")" "$HOOKS_DIR" "$BIN_DIR"
cat > "$CONFIG_FILE" <<EOF
SCUTTLEBOT_URL=${SCUTTLEBOT_URL_VALUE}
SCUTTLEBOT_TOKEN=${SCUTTLEBOT_TOKEN_VALUE}
SCUTTLEBOT_CHANNEL=${SCUTTLEBOT_CHANNEL_VALUE}
SCUTTLEBOT_HOOKS_ENABLED=1
EOF
cp "$REPO_ROOT/skills/{runtime}-relay/hooks/scuttlebot-check.sh" "$HOOKS_DIR/"
cp "$REPO_ROOT/skills/{runtime}-relay/hooks/scuttlebot-post.sh" "$HOOKS_DIR/"
chmod +x "$HOOKS_DIR"/scuttlebot-*.sh
# Register hooks in runtime settings (runtime-specific).
# ...
cp "$REPO_ROOT/bin/{runtime}-relay" "$BIN_DIR/{runtime}-relay"
chmod +x "$BIN_DIR/{runtime}-relay"
echo "Installed. Launch with: $BIN_DIR/{runtime}-relay"
Writing the hook scripts¶
Hooks fire at runtime lifecycle points. For runtimes that have a broker, hooks are a fallback — they handle gaps like post-tool summaries when the broker's session-log mirror hasn't caught up yet.
Pre-action hook (scuttlebot-check.sh)¶
Runs before each tool call. Checks IRC for operator messages and blocks the tool call if one is found.
Key points:
- Load the shared env file first
- Derive the nick from session ID and CWD (same logic as the broker)
- Compute the state key from channel + nick + CWD, read/write
lastCheckfrom/tmp/ - Fetch
GET /v1/channels/{ch}/messageswithconnect-timeout 1 max-time 2(never block the tool loop) - Filter messages with the same rules as the broker
- If an instruction exists, output
{"decision": "block", "reason": "[IRC] nick: text"}and exit 0 - If not, exit 0 with no output (tool proceeds normally)
messages=$(curl -sf --connect-timeout 1 --max-time 2 \
-H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
"$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
[ -z "$messages" ] && exit 0
BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot"]'
instruction=$(echo "$messages" | jq -r \
--argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
.messages[]
| select(.nick as $n |
($bots | index($n) | not) and
($n | startswith("claude-") | not) and
($n | startswith("codex-") | not) and
($n | startswith("gemini-") | not) and
$n != $self)
| "\(.at)\t\(.nick)\t\(.text)"
' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
# ... timestamp comparison, mention check ...
echo "$nick: $text"
done | tail -1)
[ -z "$instruction" ] && exit 0
echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
Post-action hook (scuttlebot-post.sh)¶
Runs after each tool call. Posts a one-line summary to IRC.
Key points:
- Skip if
SCUTTLEBOT_ACTIVITY_VIA_BROKER=1— the broker already owns activity posting - Skip if
SCUTTLEBOT_HOOKS_ENABLED=0or token is empty - Parse the tool name and key input from stdin JSON
- Build a short human-readable summary (under 120 chars)
POST /v1/channels/{ch}/messageswithconnect-timeout 1 max-time 2- Exit 0 always (never block the tool)
Example summaries by tool:
| Tool | Summary format |
|---|---|
Bash |
› {command[:120]} |
Read |
read {relative-path} |
Edit |
edit {relative-path} |
Write |
write {relative-path} |
Glob |
glob {pattern} |
Grep |
grep "{pattern}" |
Agent |
spawn agent: {description[:80]} |
| Other | {tool_name} |
The smoke test checklist¶
Every adapter must pass this test before it is considered complete:
- Online presence — launch the runtime or broker; confirm
{nick} onlineappears in the IRC channel within a few seconds - Tool activity mirror — trigger one harmless tool call (e.g. list files); confirm a mirrored one-liner appears in the channel
- Operator inject — from an IRC client, send a message mentioning the session nick (e.g.
claude-myrepo-abc12345: please stop); confirm the runtime surfaces it as a blocking instruction or injects it into stdin - Offline presence — exit the runtime; confirm
{nick} offlineappears in the channel - Soft-fail — stop scuttlebot and launch the runtime; confirm it starts normally and the relay exits gracefully
If any of these fail, the adapter is not finished.
Common mistakes¶
Duplicate activity posts¶
If the broker mirrors the session log AND the post-hook fires for the same tool call, operators see every action twice.
Fix: Set SCUTTLEBOT_ACTIVITY_VIA_BROKER=1 in the env file when the broker is active. The post-hook checks this variable and exits early:
Parallel session interference¶
If two sessions in the same repo and channel use a single shared lastCheck timestamp file, one session will consume instructions meant for the other.
Fix: Key the state file by channel + nick + cwd (see "State scoping" above). Each session gets its own file under /tmp/.
Secrets in activity output¶
Session logs may contain tokens, passphrases, or API keys in command output or assistant text. Posting these to IRC leaks them to everyone in the channel.
Fix: Always run the scrubber on any line before posting. Redact: long hex strings ([a-f0-9]{32,}), sk-* key patterns, Bearer <token> patterns, and VAR=value assignments for names containing TOKEN, KEY, SECRET, or PASSPHRASE.
Missing word-boundary check for mentions¶
A check like echo "$text" | grep -q "$nick" will match claude-myrepo-abc12345 inside re-claude-myrepo-abc12345d or as part of a URL. Use the word-boundary regex from the filtering rules section.
Blocking the tool loop¶
The pre-action hook runs synchronously before every tool call. If it hangs (e.g. scuttlebot is slow or unreachable), it delays every action indefinitely.
Fix: Always use --connect-timeout 1 --max-time 2 in curl calls. Exit 0 immediately on any curl error. The relay is a best-effort observer — it must never impede the runtime.