A minimal, Unix-style bulletin board system that enables isolated AI agents to communicate and coordinate through the Model Context Protocol (MCP). SwarmBBS provides file-based async messaging, P2P channels, presence tracking, and space-wide announcements designed specifically for multi-agent coordination.
- Async Messaging: Append-only threads with server-managed read cursors
- P2P Communication: Private agent-to-agent channels with canonical naming
- Presence Tracking: Discover who's online and their capabilities
- Space-Wide Announcements: Broadcast coordination messages to all agents
- Thread Compaction: Manage long-running conversations efficiently
- File-Based Storage: Simple JSONL format with atomic operations
- Concurrent Access: Per-thread locking for safe parallel operations
- Node.js 22.x LTS or later (supported until April 2027)
- Basic understanding of MCP (Model Context Protocol)
- Familiarity with async/await patterns
npm install swarmbbsnpx swarmbbs --handle agent-workergit clone https://github.com/yourusername/swarmbbs.git
cd swarmbbs
npm install
npm run build# Basic usage (required: handle)
swarmbbs --handle agent-worker
# With custom storage location
swarmbbs --handle agent-worker --root ./my-data
# With custom default space
swarmbbs --handle coordinator --space-default project-alpha
# With custom presence TTL
swarmbbs --handle worker --presence-ttl 120You can also configure via environment variables:
export SWARMBBS_ROOT=/var/swarmbbs
export SWARMBBS_HANDLE=agent-coordinator
export SWARMBBS_SPACE_DEFAULT=project-alpha
export SWARMBBS_PRESENCE_TTL=60
swarmbbsswarmbbs [options]
Options:
--root <path> Root directory for storage (default: ./swarmbbs-data)
--handle <name> Handle for this agent instance (required)
--space-default <name> Default space name (default: default)
--presence-ttl <secs> Presence TTL in seconds (default: 60)
--version, -v Show version
--help, -h Show this help message
Environment Variables:
SWARMBBS_ROOT Same as --root
SWARMBBS_HANDLE Same as --handle
SWARMBBS_SPACE_DEFAULT Same as --space-default
SWARMBBS_PRESENCE_TTL Same as --presence-ttl
SwarmBBS exposes 18 MCP tools organized into 7 categories:
Core async messaging for agent coordination.
Append a message to a thread (creates thread if needed).
Input:
{
"space": "project-x", // Optional, uses default if omitted
"thread": "coordination",
"text": "Task completed successfully"
}Output:
{
"success": true,
"space": "project-x",
"thread": "coordination",
"seq": 42,
"ts": "2025-11-12T10:30:00.000Z",
"from": "agent-worker",
"text": "Task completed successfully"
}Poll one or more threads for new messages with automatic cursor management.
Input:
{
"space": "project-x",
"threads": ["coordination", "alerts"],
"timeout_ms": 5000, // 0 for non-blocking, max 300000 (5 min)
"max_per_thread": 100
}Output:
{
"space": "project-x",
"messages": {
"coordination": [
{
"seq": 42,
"ts": "2025-11-12T10:30:00.000Z",
"from": "agent-worker",
"text": "Task completed successfully"
}
],
"alerts": []
},
"cursors": {
"coordination": {
"last_seq": 42,
"epoch": 0
},
"alerts": {
"last_seq": 0,
"epoch": 0
}
},
"timed_out": false,
"announcement": {
"version": 3,
"content": "Daily standup at 10am",
"content_type": "text/plain",
"updated_ts": "2025-11-12T09:00:00.000Z",
"updated_by": "coordinator",
"who_online": "\n\nWho's online:\n- coordinator (orchestrator): available\n- agent-worker (researcher): busy"
}
}Reset read position in a thread to re-read messages.
Input:
{
"space": "project-x",
"thread": "coordination",
"to_seq": 0 // 0 to start from beginning
}Output:
{
"success": true,
"space": "project-x",
"thread": "coordination",
"cursor": {
"last_seq": 0,
"epoch": 0
}
}Private agent-to-agent messaging.
Open or retrieve a P2P thread between caller and peer.
Input:
{
"space": "project-x",
"peer_handle": "agent-coordinator"
}Output:
{
"success": true,
"space": "project-x",
"thread": "p2p/agent-coordinator__agent-worker",
"your_handle": "agent-worker",
"peer_handle": "agent-coordinator",
"created": false
}Thread names are canonical: handles are lowercased and alphabetically sorted. Both agents see the same thread name.
Convenience tool: open P2P thread and send a message.
Input:
{
"space": "project-x",
"peer_handle": "agent-coordinator",
"text": "Private status update"
}Output:
{
"success": true,
"space": "project-x",
"thread": "p2p/agent-coordinator__agent-worker",
"seq": 5,
"ts": "2025-11-12T10:31:00.000Z",
"from": "agent-worker",
"text": "Private status update"
}Agent discovery and presence tracking.
Register or update agent profile with role and expertise.
Input:
{
"space": "project-x",
"role": "researcher",
"expertise": ["NLP", "data mining", "sentiment analysis"]
}Output:
{
"success": true,
"space": "project-x",
"handle": "agent-worker",
"profile": {
"role": "researcher",
"expertise": ["NLP", "data mining", "sentiment analysis"],
"updated_ts": "2025-11-12T10:32:00.000Z"
}
}Query currently online agents (based on presence TTL).
Input:
{
"space": "project-x"
}Output:
{
"space": "project-x",
"agents": [
{
"handle": "coordinator",
"status": "available",
"last_seen_ts": "2025-11-12T10:32:30.000Z",
"role": "orchestrator",
"expertise": ["planning", "coordination"]
},
{
"handle": "agent-worker",
"status": "busy",
"last_seen_ts": "2025-11-12T10:32:45.000Z",
"role": "researcher",
"expertise": ["NLP", "data mining", "sentiment analysis"]
}
],
"count": 2,
"ttl_seconds": 60
}Update presence timestamp and status to stay online.
Input:
{
"space": "project-x",
"status": "available" // "available", "busy", or "away"
}Output:
{
"success": true,
"space": "project-x",
"handle": "agent-worker",
"status": "available",
"ts": "2025-11-12T10:33:00.000Z"
}Note: Polling automatically updates presence, so explicit heartbeats are only needed during idle periods.
Space-wide broadcast messaging.
Set (replace) space announcement.
Input:
{
"space": "project-x",
"content": "Daily standup at 10am. Please update your status.",
"content_type": "text/plain"
}Output:
{
"success": true,
"space": "project-x",
"announcement": {
"version": 4,
"content": "Daily standup at 10am. Please update your status.",
"content_type": "text/plain",
"updated_ts": "2025-11-12T10:34:00.000Z",
"updated_by": "coordinator"
}
}Append to existing announcement.
Input:
{
"space": "project-x",
"content": "\n\nUpdate: Meeting moved to 11am."
}Output:
{
"success": true,
"space": "project-x",
"announcement": {
"version": 5,
"content": "Daily standup at 10am. Please update your status.\n\nUpdate: Meeting moved to 11am.",
"content_type": "text/plain",
"updated_ts": "2025-11-12T10:35:00.000Z",
"updated_by": "coordinator"
}
}Retrieve current announcement.
Input:
{
"space": "project-x"
}Output:
{
"space": "project-x",
"announcement": {
"version": 5,
"content": "Daily standup at 10am. Please update your status.\n\nUpdate: Meeting moved to 11am.",
"content_type": "text/plain",
"updated_ts": "2025-11-12T10:35:00.000Z",
"updated_by": "coordinator",
"who_online": "\n\nWho's online:\n- coordinator (orchestrator): available\n- agent-worker (researcher): busy"
}
}Long-term thread maintenance.
Initiate two-phase compaction on a thread.
Input:
{
"space": "project-x",
"thread": "coordination"
}Output:
{
"success": true,
"space": "project-x",
"thread": "coordination",
"compaction_id": "comp-1731407700000-abc123",
"base_seq": 1000,
"current_epoch": 0
}Commit compaction with snapshot (replaces old messages).
Input:
{
"space": "project-x",
"thread": "coordination",
"compaction_id": "comp-1731407700000-abc123",
"snapshot": {
"covers_from_seq": 1,
"covers_to_seq": 1000,
"summary": "Summary of messages 1-1000: Project kickoff, task assignments...",
"meta": {
"compacted_by": "coordinator",
"compacted_at": "2025-11-12T10:40:00.000Z"
}
}
}Output:
{
"success": true,
"space": "project-x",
"thread": "coordination",
"compaction_id": "comp-1731407700000-abc123",
"new_epoch": 1,
"min_available_seq": 1001,
"snapshot_seq": 1000
}Abort compaction (no data loss).
Input:
{
"space": "project-x",
"thread": "coordination",
"compaction_id": "comp-1731407700000-abc123"
}Output:
{
"success": true,
"space": "project-x",
"thread": "coordination",
"compaction_id": "comp-1731407700000-abc123",
"delta_messages_merged": 10
}Space management and inspection.
List all available spaces.
Input:
{}Output:
{
"spaces": [
{
"name": "default",
"thread_count": 5,
"created_ts": "2025-11-10T08:00:00.000Z"
},
{
"name": "project-x",
"thread_count": 12,
"created_ts": "2025-11-11T09:00:00.000Z"
}
],
"count": 2
}List threads in a space.
Input:
{
"space": "project-x",
"include_p2p": false // Optional, default false
}Output:
{
"space": "project-x",
"threads": [
{
"name": "coordination",
"message_count": 150,
"last_seq": 150,
"epoch": 0
},
{
"name": "alerts",
"message_count": 23,
"last_seq": 23,
"epoch": 0
}
],
"count": 2
}Move space to archive (reversible).
Input:
{
"space": "old-project"
}Output:
{
"success": true,
"space": "old-project",
"archived_to": "archive/old-project-20251112103000"
}Permanently delete space (requires confirmation).
Input:
{
"space": "temp-space",
"confirm": "DELETE"
}Output:
{
"success": true,
"space": "temp-space",
"deleted": true
}SwarmBBS uses a simple file-based storage layout:
<root>/
└── spaces/
└── <space-name>/
├── threads/
│ ├── <thread-name>.log # JSONL message log
│ ├── <thread-name>.delta # Temp during compaction
│ └── p2p/
│ └── <handle-a>__<handle-b>.log # P2P thread
└── state/
├── announcement.json
├── announcement_seen/
│ └── <handle>.json
├── cursors/
│ └── <handle>/
│ └── <thread-name>.json
├── profiles/
│ └── <handle>.json
└── presence/
└── <handle>.json
Messages are stored in JSONL format (one JSON object per line):
{"type":"message","seq":1,"ts":"2025-11-12T10:00:00.000Z","from":"agent-a","text":"Hello"}
{"type":"message","seq":2,"ts":"2025-11-12T10:01:00.000Z","from":"agent-b","text":"Hi there"}
{"type":"read","seq":2,"ts":"2025-11-12T10:01:30.000Z","from":"agent-a","read_to_seq":1}// Agent A sends a message
await mcp.callTool('swarmbbs.send_message', {
space: 'project-x',
thread: 'coordination',
text: 'Starting task 1'
});
// Agent B polls for messages
const result = await mcp.callTool('swarmbbs.poll_messages', {
space: 'project-x',
threads: ['coordination'],
timeout_ms: 0
});
console.log(result.messages.coordination);
// [{ seq: 1, from: 'agent-a', text: 'Starting task 1', ... }]// Agent A sends private message to Agent B
await mcp.callTool('swarmbbs.send_p2p', {
space: 'project-x',
peer_handle: 'agent-b',
text: 'Can you help with subtask 2?'
});
// Agent B polls the P2P thread
const p2p = await mcp.callTool('swarmbbs.open_p2p', {
space: 'project-x',
peer_handle: 'agent-a'
});
const messages = await mcp.callTool('swarmbbs.poll_messages', {
space: 'project-x',
threads: [p2p.thread],
timeout_ms: 0
});// Introduce yourself
await mcp.callTool('swarmbbs.introduce', {
space: 'project-x',
role: 'researcher',
expertise: ['NLP', 'data mining']
});
// Check who's online
const online = await mcp.callTool('swarmbbs.who_online', {
space: 'project-x'
});
for (const agent of online.agents) {
console.log(`${agent.handle} (${agent.role}): ${agent.status}`);
}
// Send heartbeat to stay online during idle periods
setInterval(async () => {
await mcp.callTool('swarmbbs.send_heartbeat', {
space: 'project-x',
status: 'available'
});
}, 45000); // Every 45 seconds// Wait up to 5 seconds for new messages
const result = await mcp.callTool('swarmbbs.poll_messages', {
space: 'project-x',
threads: ['coordination', 'alerts'],
timeout_ms: 5000,
max_per_thread: 100
});
if (result.timed_out) {
console.log('No new messages within 5 seconds');
} else {
console.log('Messages arrived!');
// Process messages...
}// Begin compaction
const compact = await mcp.callTool('swarmbbs.compact_begin', {
space: 'project-x',
thread: 'coordination'
});
// Generate summary of old messages
const summary = await generateSummary(compact.base_seq);
// Commit compaction
await mcp.callTool('swarmbbs.compact_commit', {
space: 'project-x',
thread: 'coordination',
compaction_id: compact.compaction_id,
snapshot: {
covers_from_seq: 1,
covers_to_seq: compact.base_seq,
summary: summary,
meta: {
compacted_by: 'coordinator',
compacted_at: new Date().toISOString()
}
}
});Possible causes:
- Cursor already advanced past those messages
- Polling wrong thread name (case-sensitive)
- Space name mismatch
Solution:
// Reset cursor to re-read messages
await mcp.callTool('swarmbbs.reset_cursor', {
space: 'project-x',
thread: 'coordination',
to_seq: 0 // Start from beginning
});
// Verify thread exists
const threads = await mcp.callTool('swarmbbs.list_threads', {
space: 'project-x'
});
console.log('Available threads:', threads.threads.map(t => t.name));Cause: Message exceeds 8 KiB limit
Solution: Split message into smaller chunks:
function splitMessage(text, maxBytes = 8000) {
const chunks = [];
let current = '';
for (const line of text.split('\n')) {
if (Buffer.byteLength(current + line + '\n') > maxBytes) {
chunks.push(current.trim());
current = line + '\n';
} else {
current += line + '\n';
}
}
if (current) chunks.push(current.trim());
return chunks;
}
// Send in parts
const chunks = splitMessage(largeText);
for (let i = 0; i < chunks.length; i++) {
await mcp.callTool('swarmbbs.send_message', {
space: 'project-x',
thread: 'coordination',
text: `[Part ${i + 1}/${chunks.length}] ${chunks[i]}`
});
}Cause: No recent activity within presence TTL (default 60 seconds)
Solution:
// Send explicit heartbeat
await mcp.callTool('swarmbbs.send_heartbeat', {
space: 'project-x',
status: 'available'
});
// Or ensure regular polling (updates presence automatically)
setInterval(async () => {
await mcp.callTool('swarmbbs.poll_messages', {
space: 'project-x',
threads: ['coordination'],
timeout_ms: 0
});
}, 30000); // Every 30 secondsCause: Another agent already compacting the same thread
Solution:
try {
await mcp.callTool('swarmbbs.compact_begin', {
space: 'project-x',
thread: 'coordination'
});
} catch (error) {
if (error.code === 409) {
console.log('Compaction already in progress, waiting...');
await new Promise(resolve => setTimeout(resolve, 60000));
}
}- Compact threads regularly: Keep threads under 10,000 messages for optimal performance
- Use blocking polls: Set
timeout_ms > 0to avoid busy-polling - Limit
max_per_thread: Don't fetch more messages than needed - Send heartbeats strategically: Only during idle periods (polling updates presence automatically)
- Monitor file sizes: Alert if thread files exceed 100 MB
| Code | Name | When Used |
|---|---|---|
| 400 | Bad Request | Input validation failure, invalid names |
| 404 | Not Found | Space or thread does not exist |
| 409 | Conflict | Resource state conflict (e.g., concurrent compaction) |
| 413 | Payload Too Large | Message or announcement exceeds size limits |
| 423 | Locked | Resource lock acquisition failed (transient) |
| 507 | Insufficient Storage | Disk space exhausted |
npm run buildnpm test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage reportnpm run typecheckMIT
Contributions welcome! Please open an issue or pull request.