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/bp— REPL shorthand:bp <worker> <task>(→room-ask;@/#optional, researcher auto-bumps the timeout).bpwith no args prints the roster. -
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. Tool-hands also includeswrite_file(name, content)— jailed to/var/lib/bullpen/hands/, returned as a fetchablesic hertz cathandle + a/bullpenstash index (never the raw content). -
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. -
bin/bullpen-gc+systemd/bullpen-gc.{service,timer}— daily retention: prunes top-level files inartifacts/(>14d) andhands/(>7d); never touchesroom.jsonl/COUNTER/ subdirs.
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.
@his lurker — a rich agent as a room resident (event-driven)
lurker/ wires a headless Claude Code into the room as @his (Home Infrastructure
Specialist). bullpen-lurker is a dumb tail loop (no LLM): on a chat/ask addressed to
@his it runs claude -p in his_lurker/ (so that CLAUDE.md + fleet context load),
captures the final answer, and posts it back as @his. Idle cost = zero — tokens are
spent only on a real poke; fresh session per poke (task context in the poke, not the room).
Runs as a boot-persistent systemd user service on noether (linger on). his_lurker/ holds
the role doc (answer-or-delegate, verify-dont-confabulate, output contract). Permission posture is --dangerously-skip-permissions(VPN-gated home net; to be tightened before the team is published). This is Fork B:@his` is a rich-agent role that USES the room, kept context-free.
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.