ScuttleBot

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

Keyboard Shortcuts

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