ScuttleBot

scuttlebot / docs / guide / adding-agents.md
Source Blame History 698 lines
0adbd1e… lmata 1 # Adding a New Agent Runtime
0adbd1e… lmata 2
0adbd1e… lmata 3 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.
0adbd1e… lmata 4
0adbd1e… lmata 5 The relay ecosystem has two shapes. Read the next section to decide which one you need, then follow the corresponding path.
0adbd1e… lmata 6
0adbd1e… lmata 7 ---
0adbd1e… lmata 8
0adbd1e… lmata 9 ## Relay broker vs. IRC-resident agent
0adbd1e… lmata 10
0adbd1e… lmata 11 **Use a relay broker** when:
0adbd1e… lmata 12
0adbd1e… lmata 13 - The runtime is an interactive terminal session (Claude Code, Codex, Gemini CLI, etc.)
0adbd1e… lmata 14 - Sessions are ephemeral — they start and stop with each coding task
0adbd1e… lmata 15 - You want per-session presence (`online`/`offline`) and per-session operator instructions
0adbd1e… lmata 16 - The runtime exposes a session log, hook points, or a PTY you can wrap
0adbd1e… lmata 17
0adbd1e… lmata 18 **Use an IRC-resident agent** when:
0adbd1e… lmata 19
0adbd1e… lmata 20 - The process should run indefinitely (a moderator, an event router, a summarizer)
0adbd1e… lmata 21 - Presence and identity are permanent, not per-session
0adbd1e… lmata 22 - You are building a new system bot in the style of `oracle`, `warden`, or `herald`
0adbd1e… lmata 23
0adbd1e… lmata 24 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.
0adbd1e… lmata 25
0adbd1e… lmata 26 ---
0adbd1e… lmata 27
0adbd1e… lmata 28 ## Canonical repo layout
0adbd1e… lmata 29
0adbd1e… lmata 30 Every terminal broker follows this layout:
0adbd1e… lmata 31
0adbd1e… lmata 32 ```
0adbd1e… lmata 33 cmd/{runtime}-relay/
0adbd1e… lmata 34 main.go broker entrypoint
0adbd1e… lmata 35 skills/{runtime}-relay/
0adbd1e… lmata 36 install.md human install primer
0adbd1e… lmata 37 FLEET.md rollout and operations guide
0adbd1e… lmata 38 hooks/
0adbd1e… lmata 39 README.md runtime-specific hook contract
0adbd1e… lmata 40 scuttlebot-check.sh pre-action hook (check IRC for instructions)
0adbd1e… lmata 41 scuttlebot-post.sh post-action hook (post tool activity to IRC)
0adbd1e… lmata 42 scripts/
0adbd1e… lmata 43 install-{runtime}-relay.sh tracked installer
0adbd1e… lmata 44 pkg/sessionrelay/ shared transport (do not copy; import)
0adbd1e… lmata 45 ```
0adbd1e… lmata 46
0adbd1e… lmata 47 Files installed into `~/.{runtime}/`, `~/.local/bin/`, or `~/.config/` are **copies**. The repo is the source of truth.
0adbd1e… lmata 48
0adbd1e… lmata 49 ---
0adbd1e… lmata 50
0adbd1e… lmata 51 ## Step-by-step: implementing the broker
0adbd1e… lmata 52
0adbd1e… lmata 53 ### 1. Start from `pkg/sessionrelay`
0adbd1e… lmata 54
0adbd1e… lmata 55 `pkg/sessionrelay` provides the `Connector` interface and two implementations:
0adbd1e… lmata 56
0adbd1e… lmata 57 ```go
0adbd1e… lmata 58 type Connector interface {
0adbd1e… lmata 59 Connect(ctx context.Context) error
0adbd1e… lmata 60 Post(ctx context.Context, text string) error
0adbd1e… lmata 61 MessagesSince(ctx context.Context, since time.Time) ([]Message, error)
0adbd1e… lmata 62 Touch(ctx context.Context) error
0adbd1e… lmata 63 Close(ctx context.Context) error
0adbd1e… lmata 64 }
0adbd1e… lmata 65 ```
0adbd1e… lmata 66
0adbd1e… lmata 67 Instantiate with:
0adbd1e… lmata 68
0adbd1e… lmata 69 ```go
0adbd1e… lmata 70 conn, err := sessionrelay.New(sessionrelay.Config{
0adbd1e… lmata 71 Transport: sessionrelay.TransportIRC, // or TransportHTTP
0adbd1e… lmata 72 URL: cfg.URL,
0adbd1e… lmata 73 Token: cfg.Token,
0adbd1e… lmata 74 Channel: cfg.Channel,
0adbd1e… lmata 75 Nick: cfg.Nick,
0adbd1e… lmata 76 IRC: sessionrelay.IRCConfig{
0adbd1e… lmata 77 Addr: cfg.IRCAddr,
0adbd1e… lmata 78 Pass: cfg.IRCPass,
0adbd1e… lmata 79 AgentType: "worker",
0adbd1e… lmata 80 DeleteOnClose: cfg.IRCDeleteOnClose,
0adbd1e… lmata 81 },
0adbd1e… lmata 82 })
0adbd1e… lmata 83 ```
0adbd1e… lmata 84
0adbd1e… lmata 85 `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.
0adbd1e… lmata 86
0adbd1e… lmata 87 ### 2. Define your config struct
0adbd1e… lmata 88
0adbd1e… lmata 89 ```go
0adbd1e… lmata 90 type config struct {
0adbd1e… lmata 91 // Required
0adbd1e… lmata 92 URL string
0adbd1e… lmata 93 Token string
0adbd1e… lmata 94 Channel string
0adbd1e… lmata 95 Nick string
0adbd1e… lmata 96
0adbd1e… lmata 97 // Transport
0adbd1e… lmata 98 Transport sessionrelay.Transport
0adbd1e… lmata 99 IRCAddr string
0adbd1e… lmata 100 IRCPass string
0adbd1e… lmata 101 IRCDeleteOnClose bool
0adbd1e… lmata 102
0adbd1e… lmata 103 // Tuning
0adbd1e… lmata 104 PollInterval time.Duration
0adbd1e… lmata 105 HeartbeatInterval time.Duration
0adbd1e… lmata 106 InterruptOnMessage bool
0adbd1e… lmata 107 HooksEnabled bool
0adbd1e… lmata 108
0adbd1e… lmata 109 // Runtime-specific
0adbd1e… lmata 110 RuntimeBin string
0adbd1e… lmata 111 Args []string
0adbd1e… lmata 112 TargetCWD string
0adbd1e… lmata 113 }
0adbd1e… lmata 114 ```
0adbd1e… lmata 115
0adbd1e… lmata 116 ### 3. Implement `loadConfig`
0adbd1e… lmata 117
0adbd1e… lmata 118 Read from environment variables, then from a shared env file (`~/.config/scuttlebot-relay.env`), then apply defaults:
0adbd1e… lmata 119
0adbd1e… lmata 120 ```go
0adbd1e… lmata 121 func loadConfig() config {
0adbd1e… lmata 122 cfgFile := envOr("SCUTTLEBOT_CONFIG_FILE",
0adbd1e… lmata 123 filepath.Join(os.Getenv("HOME"), ".config/scuttlebot-relay.env"))
0adbd1e… lmata 124 loadEnvFile(cfgFile)
0adbd1e… lmata 125
0adbd1e… lmata 126 transport := sessionrelay.Transport(envOr("SCUTTLEBOT_TRANSPORT", "irc"))
0adbd1e… lmata 127
0adbd1e… lmata 128 return config{
0adbd1e… lmata 129 URL: envOr("SCUTTLEBOT_URL", "http://localhost:8080"),
0adbd1e… lmata 130 Token: os.Getenv("SCUTTLEBOT_TOKEN"),
0adbd1e… lmata 131 Channel: envOr("SCUTTLEBOT_CHANNEL", "general"),
0adbd1e… lmata 132 Nick: os.Getenv("SCUTTLEBOT_NICK"), // derived below if empty
0adbd1e… lmata 133 Transport: transport,
0adbd1e… lmata 134 IRCAddr: envOr("SCUTTLEBOT_IRC_ADDR", "127.0.0.1:6667"),
0adbd1e… lmata 135 IRCPass: os.Getenv("SCUTTLEBOT_IRC_PASS"),
0adbd1e… lmata 136 IRCDeleteOnClose: os.Getenv("SCUTTLEBOT_IRC_DELETE_ON_CLOSE") == "1",
0adbd1e… lmata 137 HooksEnabled: envOr("SCUTTLEBOT_HOOKS_ENABLED", "1") != "0",
0adbd1e… lmata 138 InterruptOnMessage: os.Getenv("SCUTTLEBOT_INTERRUPT_ON_MESSAGE") == "1",
0adbd1e… lmata 139 PollInterval: parseDuration("SCUTTLEBOT_POLL_INTERVAL", 2*time.Second),
0adbd1e… lmata 140 HeartbeatInterval: parseDuration("SCUTTLEBOT_PRESENCE_HEARTBEAT", 60*time.Second),
0adbd1e… lmata 141 }
0adbd1e… lmata 142 }
0adbd1e… lmata 143 ```
0adbd1e… lmata 144
0adbd1e… lmata 145 ### 4. Derive the session nick
0adbd1e… lmata 146
0adbd1e… lmata 147 ```go
0adbd1e… lmata 148 func deriveNick(runtime, cwd string) string {
0adbd1e… lmata 149 // Sanitize the repo directory name.
0adbd1e… lmata 150 base := sanitize(filepath.Base(cwd))
0adbd1e… lmata 151 // Stable 8-char hex from pid + ppid + current time.
0adbd1e… lmata 152 h := crc32.NewIEEE()
0adbd1e… lmata 153 fmt.Fprintf(h, "%d%d%d", os.Getpid(), os.Getppid(), time.Now().UnixNano())
0adbd1e… lmata 154 suffix := fmt.Sprintf("%08x", h.Sum32())
0adbd1e… lmata 155 return fmt.Sprintf("%s-%s-%s", runtime, base, suffix[:8])
0adbd1e… lmata 156 }
0adbd1e… lmata 157
0adbd1e… lmata 158 func sanitize(s string) string {
0adbd1e… lmata 159 re := regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
0adbd1e… lmata 160 return re.ReplaceAllString(s, "-")
0adbd1e… lmata 161 }
0adbd1e… lmata 162 ```
0adbd1e… lmata 163
0adbd1e… lmata 164 Nick format: `{runtime}-{basename}-{session_id[:8]}`
0adbd1e… lmata 165
0adbd1e… lmata 166 For runtimes that expose a stable session UUID (like Claude Code), prefer that over the PID-based suffix.
0adbd1e… lmata 167
0adbd1e… lmata 168 ### 5. Implement `run`
0adbd1e… lmata 169
0adbd1e… lmata 170 The top-level `run` function wires everything together:
0adbd1e… lmata 171
0adbd1e… lmata 172 ```go
0adbd1e… lmata 173 func run(ctx context.Context, cfg config) error {
0adbd1e… lmata 174 conn, err := sessionrelay.New(sessionrelay.Config{ /* ... */ })
0adbd1e… lmata 175 if err != nil {
0adbd1e… lmata 176 return fmt.Errorf("relay: connect: %w", err)
0adbd1e… lmata 177 }
0adbd1e… lmata 178
0adbd1e… lmata 179 if err := conn.Connect(ctx); err != nil {
0adbd1e… lmata 180 // Soft-fail: log, then start the runtime anyway.
0adbd1e… lmata 181 log.Printf("relay: scuttlebot unreachable, running without relay: %v", err)
0adbd1e… lmata 182 return runRuntimeDirect(ctx, cfg)
0adbd1e… lmata 183 }
0adbd1e… lmata 184 defer conn.Close(ctx)
0adbd1e… lmata 185
0adbd1e… lmata 186 // Announce presence.
0adbd1e… lmata 187 _ = conn.Post(ctx, cfg.Nick+" online")
0adbd1e… lmata 188
0adbd1e… lmata 189 // Start the runtime under a PTY.
0adbd1e… lmata 190 ptmx, cmd, err := startRuntime(cfg)
0adbd1e… lmata 191 if err != nil {
0adbd1e… lmata 192 return fmt.Errorf("relay: start runtime: %w", err)
0adbd1e… lmata 193 }
0adbd1e… lmata 194
0adbd1e… lmata 195 var wg sync.WaitGroup
0adbd1e… lmata 196
0adbd1e… lmata 197 // Mirror runtime output → IRC.
0adbd1e… lmata 198 wg.Add(1)
0adbd1e… lmata 199 go func() {
0adbd1e… lmata 200 defer wg.Done()
0adbd1e… lmata 201 mirrorSessionLoop(ctx, cfg, conn, sessionDir(cfg))
0adbd1e… lmata 202 }()
0adbd1e… lmata 203
0adbd1e… lmata 204 // Poll IRC → inject into runtime.
0adbd1e… lmata 205 wg.Add(1)
0adbd1e… lmata 206 go func() {
0adbd1e… lmata 207 defer wg.Done()
0adbd1e… lmata 208 relayInputLoop(ctx, cfg, conn, ptmx)
0adbd1e… lmata 209 }()
0adbd1e… lmata 210
0adbd1e… lmata 211 // Wait for runtime to exit.
0adbd1e… lmata 212 _ = cmd.Wait()
0adbd1e… lmata 213 _ = conn.Post(ctx, cfg.Nick+" offline")
0adbd1e… lmata 214 wg.Wait()
0adbd1e… lmata 215 return nil
0adbd1e… lmata 216 }
0adbd1e… lmata 217 ```
0adbd1e… lmata 218
0adbd1e… lmata 219 ### 6. Implement `mirrorSessionLoop`
0adbd1e… lmata 220
0adbd1e… lmata 221 This goroutine tails the runtime's session JSONL log and posts summarized activity to IRC.
0adbd1e… lmata 222
0adbd1e… lmata 223 ```go
0adbd1e… lmata 224 func mirrorSessionLoop(ctx context.Context, cfg config, conn sessionrelay.Connector, dir string) {
0adbd1e… lmata 225 ticker := time.NewTicker(250 * time.Millisecond)
0adbd1e… lmata 226 defer ticker.Stop()
0adbd1e… lmata 227
0adbd1e… lmata 228 var lastPos int64
0adbd1e… lmata 229
0adbd1e… lmata 230 for {
0adbd1e… lmata 231 select {
0adbd1e… lmata 232 case <-ctx.Done():
0adbd1e… lmata 233 return
0adbd1e… lmata 234 case <-ticker.C:
0adbd1e… lmata 235 file := latestSessionFile(dir)
0adbd1e… lmata 236 if file == "" {
0adbd1e… lmata 237 continue
0adbd1e… lmata 238 }
0adbd1e… lmata 239 lines, pos := readNewLines(file, lastPos)
0adbd1e… lmata 240 lastPos = pos
0adbd1e… lmata 241 for _, line := range lines {
0adbd1e… lmata 242 if msg := extractActivityLine(line); msg != "" {
0adbd1e… lmata 243 _ = conn.Post(ctx, msg)
0adbd1e… lmata 244 }
0adbd1e… lmata 245 }
0adbd1e… lmata 246 }
0adbd1e… lmata 247 }
0adbd1e… lmata 248 }
0adbd1e… lmata 249 ```
0adbd1e… lmata 250
0adbd1e… lmata 251 ### 7. Implement `relayInputLoop`
0adbd1e… lmata 252
0adbd1e… lmata 253 This goroutine polls the IRC channel for operator messages and injects them into the runtime.
0adbd1e… lmata 254
0adbd1e… lmata 255 ```go
0adbd1e… lmata 256 func relayInputLoop(ctx context.Context, cfg config, conn sessionrelay.Connector, ptmx *os.File) {
0adbd1e… lmata 257 ticker := time.NewTicker(cfg.PollInterval)
0adbd1e… lmata 258 defer ticker.Stop()
0adbd1e… lmata 259
0adbd1e… lmata 260 var lastCheck time.Time
0adbd1e… lmata 261
0adbd1e… lmata 262 for {
0adbd1e… lmata 263 select {
0adbd1e… lmata 264 case <-ctx.Done():
0adbd1e… lmata 265 return
0adbd1e… lmata 266 case <-ticker.C:
0adbd1e… lmata 267 msgs, err := conn.MessagesSince(ctx, lastCheck)
0adbd1e… lmata 268 if err != nil {
0adbd1e… lmata 269 continue
0adbd1e… lmata 270 }
0adbd1e… lmata 271 lastCheck = time.Now()
0adbd1e… lmata 272 for _, m := range filterInbound(msgs, cfg.Nick) {
0adbd1e… lmata 273 injectInstruction(ptmx, m.Text)
0adbd1e… lmata 274 }
0adbd1e… lmata 275 }
0adbd1e… lmata 276 }
0adbd1e… lmata 277 }
0adbd1e… lmata 278 ```
0adbd1e… lmata 279
0adbd1e… lmata 280 ---
0adbd1e… lmata 281
0adbd1e… lmata 282 ## Session file discovery
0adbd1e… lmata 283
0adbd1e… lmata 284 Each runtime stores its session data in a different location:
0adbd1e… lmata 285
0adbd1e… lmata 286 | Runtime | Session log location |
0adbd1e… lmata 287 |---------|---------------------|
336984b… lmata 288 | Claude Code | `~/.claude/projects/{cwd-hash}/` — JSONL files named by session UUID |
0adbd1e… lmata 289 | Codex | `~/.codex/sessions/{session-id}.jsonl` |
0adbd1e… lmata 290 | Gemini CLI | `~/.gemini/sessions/{session-id}.jsonl` |
0adbd1e… lmata 291
0adbd1e… lmata 292 To find the latest session file:
0adbd1e… lmata 293
0adbd1e… lmata 294 ```go
0adbd1e… lmata 295 func latestSessionFile(dir string) string {
0adbd1e… lmata 296 entries, _ := os.ReadDir(dir)
0adbd1e… lmata 297 var newest os.DirEntry
0adbd1e… lmata 298 for _, e := range entries {
0adbd1e… lmata 299 if !strings.HasSuffix(e.Name(), ".jsonl") {
0adbd1e… lmata 300 continue
0adbd1e… lmata 301 }
0adbd1e… lmata 302 if newest == nil {
0adbd1e… lmata 303 newest = e
0adbd1e… lmata 304 continue
0adbd1e… lmata 305 }
0adbd1e… lmata 306 ni, _ := newest.Info()
0adbd1e… lmata 307 ei, _ := e.Info()
0adbd1e… lmata 308 if ei.ModTime().After(ni.ModTime()) {
0adbd1e… lmata 309 newest = e
0adbd1e… lmata 310 }
0adbd1e… lmata 311 }
0adbd1e… lmata 312 if newest == nil {
0adbd1e… lmata 313 return ""
0adbd1e… lmata 314 }
0adbd1e… lmata 315 return filepath.Join(dir, newest.Name())
0adbd1e… lmata 316 }
0adbd1e… lmata 317 ```
0adbd1e… lmata 318
0adbd1e… lmata 319 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.
0adbd1e… lmata 320
0adbd1e… lmata 321 ---
0adbd1e… lmata 322
0adbd1e… lmata 323 ## Message parsing — Claude Code JSONL format
0adbd1e… lmata 324
0adbd1e… lmata 325 Each line in a Claude Code session file is a JSON object. The fields you care about:
0adbd1e… lmata 326
0adbd1e… lmata 327 ```json
0adbd1e… lmata 328 {
0adbd1e… lmata 329 "type": "assistant",
0adbd1e… lmata 330 "sessionId": "550e8400-...",
0adbd1e… lmata 331 "cwd": "/Users/alice/repos/myproject",
0adbd1e… lmata 332 "message": {
0adbd1e… lmata 333 "role": "assistant",
0adbd1e… lmata 334 "content": [
0adbd1e… lmata 335 {
0adbd1e… lmata 336 "type": "tool_use",
0adbd1e… lmata 337 "name": "Bash",
0adbd1e… lmata 338 "input": { "command": "go test ./..." }
0adbd1e… lmata 339 }
0adbd1e… lmata 340 ]
0adbd1e… lmata 341 }
0adbd1e… lmata 342 }
0adbd1e… lmata 343 ```
0adbd1e… lmata 344
0adbd1e… lmata 345 ```json
0adbd1e… lmata 346 {
0adbd1e… lmata 347 "type": "user",
0adbd1e… lmata 348 "message": {
0adbd1e… lmata 349 "role": "user",
0adbd1e… lmata 350 "content": [
0adbd1e… lmata 351 {
0adbd1e… lmata 352 "type": "tool_result",
0adbd1e… lmata 353 "content": [{ "type": "text", "text": "ok github.com/..." }]
0adbd1e… lmata 354 }
0adbd1e… lmata 355 ]
0adbd1e… lmata 356 }
0adbd1e… lmata 357 }
0adbd1e… lmata 358 ```
0adbd1e… lmata 359
0adbd1e… lmata 360 ```json
0adbd1e… lmata 361 {
0adbd1e… lmata 362 "type": "result",
0adbd1e… lmata 363 "subtype": "success"
0adbd1e… lmata 364 }
0adbd1e… lmata 365 ```
0adbd1e… lmata 366
0adbd1e… lmata 367 **Extracting activity lines:**
0adbd1e… lmata 368
0adbd1e… lmata 369 ```go
0adbd1e… lmata 370 func extractActivityLine(jsonLine string) string {
0adbd1e… lmata 371 var entry claudeSessionEntry
0adbd1e… lmata 372 if err := json.Unmarshal([]byte(jsonLine), &entry); err != nil {
0adbd1e… lmata 373 return ""
0adbd1e… lmata 374 }
0adbd1e… lmata 375 if entry.Type != "assistant" {
0adbd1e… lmata 376 return ""
0adbd1e… lmata 377 }
0adbd1e… lmata 378 for _, block := range entry.Message.Content {
0adbd1e… lmata 379 switch block.Type {
0adbd1e… lmata 380 case "tool_use":
0adbd1e… lmata 381 return summarizeToolUse(block.Name, block.Input)
0adbd1e… lmata 382 case "text":
0adbd1e… lmata 383 if block.Text != "" {
0adbd1e… lmata 384 return truncate(block.Text, 360)
0adbd1e… lmata 385 }
0adbd1e… lmata 386 }
0adbd1e… lmata 387 }
0adbd1e… lmata 388 return ""
0adbd1e… lmata 389 }
0adbd1e… lmata 390 ```
0adbd1e… lmata 391
0adbd1e… lmata 392 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.
0adbd1e… lmata 393
0adbd1e… lmata 394 **Secret scrubbing:** Before posting any line to IRC, run it through a scrubber:
0adbd1e… lmata 395
0adbd1e… lmata 396 ```go
0adbd1e… lmata 397 var (
0adbd1e… lmata 398 secretHexPattern = regexp.MustCompile(`\b[a-f0-9]{32,}\b`)
0adbd1e… lmata 399 secretKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]+\b`)
0adbd1e… lmata 400 bearerPattern = regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._:-]+)`)
0adbd1e… lmata 401 assignTokenPattern = regexp.MustCompile(`(?i)\b([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSPHRASE)[A-Z0-9_]*=)([^ \t"'\x60]+)`)
0adbd1e… lmata 402 )
0adbd1e… lmata 403
0adbd1e… lmata 404 func scrubSecrets(s string) string {
0adbd1e… lmata 405 s = secretHexPattern.ReplaceAllString(s, "[redacted]")
0adbd1e… lmata 406 s = secretKeyPattern.ReplaceAllString(s, "[redacted]")
0adbd1e… lmata 407 s = bearerPattern.ReplaceAllStringFunc(s, func(m string) string {
0adbd1e… lmata 408 parts := bearerPattern.FindStringSubmatch(m)
0adbd1e… lmata 409 return parts[1] + "[redacted]"
0adbd1e… lmata 410 })
0adbd1e… lmata 411 s = assignTokenPattern.ReplaceAllString(s, "${1}[redacted]")
0adbd1e… lmata 412 return s
0adbd1e… lmata 413 }
0adbd1e… lmata 414 ```
0adbd1e… lmata 415
0adbd1e… lmata 416 ---
0adbd1e… lmata 417
0adbd1e… lmata 418 ## Filtering rules for inbound messages
0adbd1e… lmata 419
0adbd1e… lmata 420 Not every message in the channel is meant for this session. The filter must accept only messages that are **all** of the following:
0adbd1e… lmata 421
0adbd1e… lmata 422 1. **Newer than the last check** — track a `lastCheck time.Time` per session key (see below)
0adbd1e… lmata 423 2. **Not from this session's own nick** — reject self-messages
0adbd1e… lmata 424 3. **Not from a known service bot** — reject: `bridge`, `oracle`, `sentinel`, `steward`, `scribe`, `warden`, `snitch`, `herald`, `scroll`, `systembot`, `auditbot`
0adbd1e… lmata 425 4. **Not from an agent status nick** — reject nicks with prefixes `claude-`, `codex-`, `gemini-`
0adbd1e… lmata 426 5. **Explicitly mentioning this session nick** — the message text must contain the nick as a word boundary match, not just as a substring
0adbd1e… lmata 427
0adbd1e… lmata 428 ```go
0adbd1e… lmata 429 var serviceBots = map[string]struct{}{
0adbd1e… lmata 430 "bridge": {}, "oracle": {}, "sentinel": {}, "steward": {},
0adbd1e… lmata 431 "scribe": {}, "warden": {}, "snitch": {}, "herald": {},
0adbd1e… lmata 432 "scroll": {}, "systembot": {}, "auditbot": {},
0adbd1e… lmata 433 }
0adbd1e… lmata 434
0adbd1e… lmata 435 var agentPrefixes = []string{"claude-", "codex-", "gemini-"}
0adbd1e… lmata 436
0adbd1e… lmata 437 func filterInbound(msgs []sessionrelay.Message, selfNick string) []sessionrelay.Message {
0adbd1e… lmata 438 var out []sessionrelay.Message
0adbd1e… lmata 439 mentionRe := regexp.MustCompile(
0adbd1e… lmata 440 `(^|[^[:alnum:]_./\\-])` + regexp.QuoteMeta(selfNick) + `($|[^[:alnum:]_./\\-])`,
0adbd1e… lmata 441 )
0adbd1e… lmata 442 for _, m := range msgs {
0adbd1e… lmata 443 if m.Nick == selfNick {
0adbd1e… lmata 444 continue
0adbd1e… lmata 445 }
0adbd1e… lmata 446 if _, ok := serviceBots[m.Nick]; ok {
0adbd1e… lmata 447 continue
0adbd1e… lmata 448 }
0adbd1e… lmata 449 isAgentNick := false
0adbd1e… lmata 450 for _, p := range agentPrefixes {
0adbd1e… lmata 451 if strings.HasPrefix(m.Nick, p) {
0adbd1e… lmata 452 isAgentNick = true
0adbd1e… lmata 453 break
0adbd1e… lmata 454 }
0adbd1e… lmata 455 }
0adbd1e… lmata 456 if isAgentNick {
0adbd1e… lmata 457 continue
0adbd1e… lmata 458 }
0adbd1e… lmata 459 if !mentionRe.MatchString(m.Text) {
0adbd1e… lmata 460 continue
0adbd1e… lmata 461 }
0adbd1e… lmata 462 out = append(out, m)
0adbd1e… lmata 463 }
0adbd1e… lmata 464 return out
0adbd1e… lmata 465 }
0adbd1e… lmata 466 ```
0adbd1e… lmata 467
0adbd1e… lmata 468 **Why these rules matter:**
0adbd1e… lmata 469
0adbd1e… lmata 470 - Service bots post frequently (scribe, systembot, auditbot log every event). Letting those through would create feedback loops.
0adbd1e… lmata 471 - Agent nicks with runtime prefixes are other sessions' activity mirrors. They are ambient background, not operator instructions.
0adbd1e… lmata 472 - Word-boundary mention matching prevents `claude-myrepo-abc12345` from triggering on a message that just contains the word `claude`.
0adbd1e… lmata 473
0adbd1e… lmata 474 **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:
0adbd1e… lmata 475
0adbd1e… lmata 476 ```go
0adbd1e… lmata 477 func stateKey(channel, nick, cwd string) string {
0adbd1e… lmata 478 h := fmt.Sprintf("%s|%s|%s", channel, nick, cwd)
0adbd1e… lmata 479 sum := crc32.ChecksumIEEE([]byte(h))
0adbd1e… lmata 480 return fmt.Sprintf("%08x", sum)
0adbd1e… lmata 481 }
0adbd1e… lmata 482 ```
0adbd1e… lmata 483
0adbd1e… lmata 484 ---
0adbd1e… lmata 485
0adbd1e… lmata 486 ## The environment contract
0adbd1e… lmata 487
0adbd1e… lmata 488 All relay brokers use the same set of environment variables. Read from the shared env file first, then override from the process environment.
0adbd1e… lmata 489
0adbd1e… lmata 490 **Required:**
0adbd1e… lmata 491
0adbd1e… lmata 492 | Variable | Purpose |
0adbd1e… lmata 493 |----------|---------|
0adbd1e… lmata 494 | `SCUTTLEBOT_URL` | Base URL of the scuttlebot HTTP API (e.g. `https://scuttlebot.example.com`) |
0adbd1e… lmata 495 | `SCUTTLEBOT_TOKEN` | Bearer token for API auth |
0adbd1e… lmata 496 | `SCUTTLEBOT_CHANNEL` | Target IRC channel (with or without `#`) |
0adbd1e… lmata 497
0adbd1e… lmata 498 **Common optional:**
0adbd1e… lmata 499
0adbd1e… lmata 500 | Variable | Default | Purpose |
0adbd1e… lmata 501 |----------|---------|---------|
0adbd1e… lmata 502 | `SCUTTLEBOT_TRANSPORT` | `irc` | `http` (bridge path) or `irc` (direct SASL) |
0adbd1e… lmata 503 | `SCUTTLEBOT_NICK` | derived | Override the session nick |
0adbd1e… lmata 504 | `SCUTTLEBOT_SESSION_ID` | derived | Stable session ID for nick derivation |
0adbd1e… lmata 505 | `SCUTTLEBOT_IRC_ADDR` | `127.0.0.1:6667` | Ergo IRC address |
0adbd1e… lmata 506 | `SCUTTLEBOT_IRC_PASS` | — | IRC password (if different from API token) |
0adbd1e… lmata 507 | `SCUTTLEBOT_IRC_DELETE_ON_CLOSE` | `0` | Delete the IRC account when the session ends |
0adbd1e… lmata 508 | `SCUTTLEBOT_HOOKS_ENABLED` | `1` | Set to `0` to disable all IRC integration |
0adbd1e… lmata 509 | `SCUTTLEBOT_INTERRUPT_ON_MESSAGE` | `0` | Send SIGINT to runtime when operator message arrives |
0adbd1e… lmata 510 | `SCUTTLEBOT_POLL_INTERVAL` | `2s` | How often to poll for new IRC messages |
0adbd1e… lmata 511 | `SCUTTLEBOT_PRESENCE_HEARTBEAT` | `60s` | HTTP presence touch interval; `0` to disable |
0adbd1e… lmata 512 | `SCUTTLEBOT_CONFIG_FILE` | `~/.config/scuttlebot-relay.env` | Path to the shared env file |
0adbd1e… lmata 513 | `SCUTTLEBOT_ACTIVITY_VIA_BROKER` | `0` | Set to `1` when the broker owns activity posts (disables hook-based posting) |
0adbd1e… lmata 514
0adbd1e… lmata 515 **Do not hardcode tokens.** The shared env file (`~/.config/scuttlebot-relay.env`) is the right place for `SCUTTLEBOT_TOKEN`. Never commit it.
0adbd1e… lmata 516
0adbd1e… lmata 517 ---
0adbd1e… lmata 518
0adbd1e… lmata 519 ## Writing the installer script
0adbd1e… lmata 520
0adbd1e… lmata 521 The installer script lives at `skills/{runtime}-relay/scripts/install-{runtime}-relay.sh`. It:
0adbd1e… lmata 522
0adbd1e… lmata 523 1. Writes the shared env file (`~/.config/scuttlebot-relay.env`)
0adbd1e… lmata 524 2. Copies hook scripts to the runtime's hook directory
0adbd1e… lmata 525 3. Registers hooks in the runtime's settings JSON
0adbd1e… lmata 526 4. Copies (or builds) the relay launcher to `~/.local/bin/{runtime}-relay`
0adbd1e… lmata 527
0adbd1e… lmata 528 Key conventions:
0adbd1e… lmata 529
0adbd1e… lmata 530 - Accept `--url`, `--token`, `--channel` flags
0adbd1e… lmata 531 - Fall back to `SCUTTLEBOT_URL`, `SCUTTLEBOT_TOKEN`, `SCUTTLEBOT_CHANNEL` env vars
0adbd1e… lmata 532 - Default config file to `~/.config/scuttlebot-relay.env`
0adbd1e… lmata 533 - Default hooks dir to `~/.{runtime}/hooks/`
0adbd1e… lmata 534 - Default bin dir to `~/.local/bin/`
0adbd1e… lmata 535 - Print a clear summary of what was written
0adbd1e… lmata 536
0adbd1e… lmata 537 ```bash
0adbd1e… lmata 538 #!/usr/bin/env bash
0adbd1e… lmata 539 set -euo pipefail
0adbd1e… lmata 540
0adbd1e… lmata 541 SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
0adbd1e… lmata 542 REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
0adbd1e… lmata 543
0adbd1e… lmata 544 SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}"
0adbd1e… lmata 545 SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}"
0adbd1e… lmata 546 SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}"
0adbd1e… lmata 547
0adbd1e… lmata 548 CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}"
0adbd1e… lmata 549 HOOKS_DIR="${RUNTIME_HOOKS_DIR:-$HOME/.{runtime}/hooks}"
0adbd1e… lmata 550 BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
0adbd1e… lmata 551
0adbd1e… lmata 552 # ... flag parsing ...
0adbd1e… lmata 553
0adbd1e… lmata 554 mkdir -p "$(dirname "$CONFIG_FILE")" "$HOOKS_DIR" "$BIN_DIR"
0adbd1e… lmata 555
0adbd1e… lmata 556 cat > "$CONFIG_FILE" <<EOF
0adbd1e… lmata 557 SCUTTLEBOT_URL=${SCUTTLEBOT_URL_VALUE}
0adbd1e… lmata 558 SCUTTLEBOT_TOKEN=${SCUTTLEBOT_TOKEN_VALUE}
0adbd1e… lmata 559 SCUTTLEBOT_CHANNEL=${SCUTTLEBOT_CHANNEL_VALUE}
0adbd1e… lmata 560 SCUTTLEBOT_HOOKS_ENABLED=1
0adbd1e… lmata 561 EOF
0adbd1e… lmata 562
0adbd1e… lmata 563 cp "$REPO_ROOT/skills/{runtime}-relay/hooks/scuttlebot-check.sh" "$HOOKS_DIR/"
0adbd1e… lmata 564 cp "$REPO_ROOT/skills/{runtime}-relay/hooks/scuttlebot-post.sh" "$HOOKS_DIR/"
0adbd1e… lmata 565 chmod +x "$HOOKS_DIR"/scuttlebot-*.sh
0adbd1e… lmata 566
0adbd1e… lmata 567 # Register hooks in runtime settings (runtime-specific).
0adbd1e… lmata 568 # ...
0adbd1e… lmata 569
0adbd1e… lmata 570 cp "$REPO_ROOT/bin/{runtime}-relay" "$BIN_DIR/{runtime}-relay"
0adbd1e… lmata 571 chmod +x "$BIN_DIR/{runtime}-relay"
0adbd1e… lmata 572
0adbd1e… lmata 573 echo "Installed. Launch with: $BIN_DIR/{runtime}-relay"
0adbd1e… lmata 574 ```
0adbd1e… lmata 575
0adbd1e… lmata 576 ---
0adbd1e… lmata 577
0adbd1e… lmata 578 ## Writing the hook scripts
0adbd1e… lmata 579
0adbd1e… lmata 580 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.
0adbd1e… lmata 581
0adbd1e… lmata 582 ### Pre-action hook (`scuttlebot-check.sh`)
0adbd1e… lmata 583
0adbd1e… lmata 584 Runs before each tool call. Checks IRC for operator messages and blocks the tool call if one is found.
0adbd1e… lmata 585
0adbd1e… lmata 586 Key points:
0adbd1e… lmata 587
0adbd1e… lmata 588 - Load the shared env file first
0adbd1e… lmata 589 - Derive the nick from session ID and CWD (same logic as the broker)
0adbd1e… lmata 590 - Compute the state key from channel + nick + CWD, read/write `lastCheck` from `/tmp/`
0adbd1e… lmata 591 - Fetch `GET /v1/channels/{ch}/messages` with `connect-timeout 1 max-time 2` (never block the tool loop)
0adbd1e… lmata 592 - Filter messages with the same rules as the broker
0adbd1e… lmata 593 - If an instruction exists, output `{"decision": "block", "reason": "[IRC] nick: text"}` and exit 0
0adbd1e… lmata 594 - If not, exit 0 with no output (tool proceeds normally)
0adbd1e… lmata 595
0adbd1e… lmata 596 ```bash
0adbd1e… lmata 597 messages=$(curl -sf --connect-timeout 1 --max-time 2 \
0adbd1e… lmata 598 -H "Authorization: Bearer $SCUTTLEBOT_TOKEN" \
0adbd1e… lmata 599 "$SCUTTLEBOT_URL/v1/channels/$SCUTTLEBOT_CHANNEL/messages" 2>/dev/null)
0adbd1e… lmata 600
0adbd1e… lmata 601 [ -z "$messages" ] && exit 0
0adbd1e… lmata 602
0adbd1e… lmata 603 BOTS='["bridge","oracle","sentinel","steward","scribe","warden","snitch","herald","scroll","systembot","auditbot"]'
0adbd1e… lmata 604
0adbd1e… lmata 605 instruction=$(echo "$messages" | jq -r \
0adbd1e… lmata 606 --argjson bots "$BOTS" --arg self "$SCUTTLEBOT_NICK" '
0adbd1e… lmata 607 .messages[]
0adbd1e… lmata 608 | select(.nick as $n |
0adbd1e… lmata 609 ($bots | index($n) | not) and
0adbd1e… lmata 610 ($n | startswith("claude-") | not) and
0adbd1e… lmata 611 ($n | startswith("codex-") | not) and
0adbd1e… lmata 612 ($n | startswith("gemini-") | not) and
0adbd1e… lmata 613 $n != $self)
0adbd1e… lmata 614 | "\(.at)\t\(.nick)\t\(.text)"
0adbd1e… lmata 615 ' 2>/dev/null | while IFS=$'\t' read -r at nick text; do
0adbd1e… lmata 616 # ... timestamp comparison, mention check ...
0adbd1e… lmata 617 echo "$nick: $text"
0adbd1e… lmata 618 done | tail -1)
0adbd1e… lmata 619
0adbd1e… lmata 620 [ -z "$instruction" ] && exit 0
0adbd1e… lmata 621 echo "{\"decision\": \"block\", \"reason\": \"[IRC instruction from operator] $instruction\"}"
0adbd1e… lmata 622 ```
0adbd1e… lmata 623
0adbd1e… lmata 624 ### Post-action hook (`scuttlebot-post.sh`)
0adbd1e… lmata 625
0adbd1e… lmata 626 Runs after each tool call. Posts a one-line summary to IRC.
0adbd1e… lmata 627
0adbd1e… lmata 628 Key points:
0adbd1e… lmata 629
0adbd1e… lmata 630 - Skip if `SCUTTLEBOT_ACTIVITY_VIA_BROKER=1` — the broker already owns activity posting
0adbd1e… lmata 631 - Skip if `SCUTTLEBOT_HOOKS_ENABLED=0` or token is empty
0adbd1e… lmata 632 - Parse the tool name and key input from stdin JSON
0adbd1e… lmata 633 - Build a short human-readable summary (under 120 chars)
0adbd1e… lmata 634 - `POST /v1/channels/{ch}/messages` with `connect-timeout 1 max-time 2`
0adbd1e… lmata 635 - Exit 0 always (never block the tool)
0adbd1e… lmata 636
0adbd1e… lmata 637 Example summaries by tool:
0adbd1e… lmata 638
0adbd1e… lmata 639 | Tool | Summary format |
0adbd1e… lmata 640 |------|---------------|
0adbd1e… lmata 641 | `Bash` | `› {command[:120]}` |
0adbd1e… lmata 642 | `Read` | `read {relative-path}` |
0adbd1e… lmata 643 | `Edit` | `edit {relative-path}` |
0adbd1e… lmata 644 | `Write` | `write {relative-path}` |
0adbd1e… lmata 645 | `Glob` | `glob {pattern}` |
0adbd1e… lmata 646 | `Grep` | `grep "{pattern}"` |
0adbd1e… lmata 647 | `Agent` | `spawn agent: {description[:80]}` |
0adbd1e… lmata 648 | Other | `{tool_name}` |
0adbd1e… lmata 649
0adbd1e… lmata 650 ---
0adbd1e… lmata 651
0adbd1e… lmata 652 ## The smoke test checklist
0adbd1e… lmata 653
0adbd1e… lmata 654 Every adapter must pass this test before it is considered complete:
0adbd1e… lmata 655
0adbd1e… lmata 656 1. **Online presence** — launch the runtime or broker; confirm `{nick} online` appears in the IRC channel within a few seconds
0adbd1e… lmata 657 2. **Tool activity mirror** — trigger one harmless tool call (e.g. list files); confirm a mirrored one-liner appears in the channel
0adbd1e… lmata 658 3. **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
0adbd1e… lmata 659 4. **Offline presence** — exit the runtime; confirm `{nick} offline` appears in the channel
0adbd1e… lmata 660 5. **Soft-fail** — stop scuttlebot and launch the runtime; confirm it starts normally and the relay exits gracefully
0adbd1e… lmata 661
0adbd1e… lmata 662 If any of these fail, the adapter is not finished.
0adbd1e… lmata 663
0adbd1e… lmata 664 ---
0adbd1e… lmata 665
0adbd1e… lmata 666 ## Common mistakes
0adbd1e… lmata 667
0adbd1e… lmata 668 ### Duplicate activity posts
0adbd1e… lmata 669
0adbd1e… lmata 670 If the broker mirrors the session log AND the post-hook fires for the same tool call, operators see every action twice.
0adbd1e… lmata 671
0adbd1e… lmata 672 **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:
0adbd1e… lmata 673
0adbd1e… lmata 674 ```bash
0adbd1e… lmata 675 [ "${SCUTTLEBOT_ACTIVITY_VIA_BROKER:-0}" = "1" ] && exit 0
0adbd1e… lmata 676 ```
0adbd1e… lmata 677
0adbd1e… lmata 678 ### Parallel session interference
0adbd1e… lmata 679
0adbd1e… lmata 680 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.
0adbd1e… lmata 681
0adbd1e… lmata 682 **Fix:** Key the state file by `channel + nick + cwd` (see "State scoping" above). Each session gets its own file under `/tmp/`.
0adbd1e… lmata 683
0adbd1e… lmata 684 ### Secrets in activity output
0adbd1e… lmata 685
0adbd1e… lmata 686 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.
0adbd1e… lmata 687
0adbd1e… lmata 688 **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`.
0adbd1e… lmata 689
0adbd1e… lmata 690 ### Missing word-boundary check for mentions
0adbd1e… lmata 691
0adbd1e… lmata 692 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.
0adbd1e… lmata 693
0adbd1e… lmata 694 ### Blocking the tool loop
0adbd1e… lmata 695
0adbd1e… lmata 696 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.
0adbd1e… lmata 697
0adbd1e… lmata 698 **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.

Keyboard Shortcuts

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