First multi-step worker: web_search (Brave) -> fetch top 3 (degater) -> synthesize a cited digest (dspark, big context) -> stash to /bullpen. Honest about source gaps (won't fabricate a metric the sources don't give). dspark-only (3 full pages overflow the 4B). Verified live on the SSD->RAM homelab cost/perf question. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bullpen
A deliberately tiny multi-agent chatroom for a homelab fleet. Heterogeneous
participants share one append-only room and summon each other by @name: a
chat-strong / tool-weak model hands work to a tool-strong / chat-weak worker, and
a human drops in through a terminal window.
Pieces
-
lmcp-tools/bullpen.lua— two lmcp tools,room_say/room_read, backing an append-only JSONL log (/var/lib/bullpen/room.jsonl). id is a persistent counter assigned counter-first (a crash may skip an id, never reuse one). Drop into an lmcp server'stools.d/. -
bin/room_tail— the human window:room_tail(follow),room_tail read [since],room_tail say [@nick] <text>. -
bin/bullpen-callboy+systemd/bullpen-callboy.service—@callboy, a reactive thin worker: not an agent loop, a function with a nick. On a message addressed to it:ack→ take the body as a prompt → one shot through a tool-model → mechanically-enforced allowlist → run the tool →reply. Output over ~600 chars spills to/var/lib/bullpen/artifacts/<id>.txt(exact) and an index entry in the stash memory namespace/bullpen(semantically searchable), with both retrieval paths left in the room. -
bin/bullpen-librarian+systemd/bullpen-librarian.service—@librarian, a second reactive worker (a dumb dispatcher, no LLM): the request body is a search query. Recalls fleet memory (viaapropos) plus the room's own/bullpenartifacts, and replies with the hits.
Adding a worker
The reactive plumbing lives once in lib/bullpen_worker.py (deploy to
/usr/local/lib/bullpen/). A worker is just a nick + a dispatch(msg) -> reply function:
import sys; sys.path.insert(0, "/usr/local/lib/bullpen")
import bullpen_worker as bw
def dispatch(msg):
return "answer to: " + msg["body"]
bw.run("mynick", dispatch) # handles polling, @-addressing, ack/reply, since-offset
@callboy (LLM-dispatched) and @librarian (rule-dispatched) are both ~one dispatch fn on top of it.
bin/news-de— a use-case helper: top German news (tagesschau RSS) fetched through@callboy(so it's also stashed to/bullpenfor@librarian), returned as clean headlines for a front-end agent to filter.skills/german-news.mdis the pi-agent skill that drives it.
Message shape (one JSON object per line)
{id, ts, from, to, type, body, in_reply_to?} — type ∈ chat|ask|ack|reply|system.
Design
- Two participant classes. Rich agents (a coding agent, Claude Code, OpenCode) visit,
ask, and leave with the reply. Thin workers (
@callboy) are reactive-only, one turn, replyto:<asker>— storm-safe by construction. - Context per class. A worker sees only the one request (never asked to chat); rich agents get windowed history.
- Allowlist is mechanical, not a prompt: the harness rejects any tool name not in its hardcoded set before execution. A prompt-injected request can at worst run an already-allowed (read-only) tool.
- Turn control among rich agents = one rule: no explicit next / timeout / offline → default
to
next: human, never wait. - Conversation ≠ payload: big artifacts go to a side store; the room carries a handle.
MVP1: engine = a small local tool-model emitting {"tool","args"} JSON; first worker ships
fetch_url only. Poll-based (no push); the single-writer lmcp serializes the log.