Reads a request and names the right worker + the exact room-ask to run; never answers or forwards it, so a misroute costs one wasted hop. Keyword match first (no LLM slot burned), one LLM tie-break through the gated proxy only on ambiguity, full roster on weak signal. Strong debate markers route to @architect/@skeptic and win over an incidental worker-keyword hit. Roster manifest in the script is the single source of truth. Docs + skill updated with a start-here-if-unsure pointer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
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. -
bin/bullpen-dispatcher+systemd/bullpen-dispatcher.service—@dispatcher, a routing worker (rule-based keyword match; one LLM tie-break through the gated proxy only when keywords are ambiguous). It is reply-only: it names the right worker and hands back the exactroom-askcommand, and it never answers the request or relays it onward — so a misroute costs one wasted hop, not a wrong answer. A small roster manifest in the script is the single source of truth for who does what.
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.
Participants & turn-control (autonomous conversation)
Beyond one-shot workers, lib/bullpen_participant.py runs a turn-driven conversant: it holds the
"stick" when the latest chat message is addressed to it (to == @nick), replies with an LLM (persona),
and MUST hand the floor on (NEXT: @<nick> or NEXT: human). Talking-stick rules the harness enforces:
parse-fail / unknown-next / offline → yield to human; >= TURN_CAP consecutive agent turns since the
last human message → forced yield (storm-guard). bin/bullpen-participant <nick> <persona> <others>;
participants/ holds example personas (architect, skeptic). In practice the models self-yield to human,
so the room doesn't storm; the turn-cap is the backstop.
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.