Four primitives for multi-agent LLM systems:
- Agent — message queue + LLM.
queue(),send(),ask(). - Bus — pub/sub event routing between agents.
- Timeline — append-only event log for replay and export.
- Session — shared state persistence with audit trail.
Zero dependencies. File-first by default. Bring your own backend if you want.
- Mix models freely — OpenAI, Anthropic, xAI, Gemini, Ollama
- Append-only JSONL persistence — debuggable and crash-safe
- Parallel by default — each agent owns its own state
- Hooks + governance for observability, budgets, and compliance
import { Agent, loadProviders } from './index.js';
await loadProviders({
anthropic: { apiKey: process.env.ANTHROPIC_API_KEY, models: { sonnet: 'claude-sonnet-4-6' }, enabled: ['sonnet'] },
});
const agent = new Agent({ id: 'assistant', systemPrompt: 'You are helpful.', model: 'sonnet' });
console.log(await agent.ask('What is the capital of France?'));One agent. One model. Done.
A lead generation pipeline that turns public data into qualified contractor leads:
import { Agent, loadProviders } from './index.js';
await loadProviders({ /* your providers */ });
const inspector = new Agent({ id: 'inspector', systemPrompt: 'You are a veteran home inspector...', model: 'grok' });
const estimator = new Agent({ id: 'estimator', systemPrompt: 'You are a cost estimator...', model: 'sonnet' });
const writer = new Agent({ id: 'writer', systemPrompt: 'You are a direct-response copywriter...', model: 'opus' });
const qualifier = new Agent({ id: 'qualifier', systemPrompt: 'You are a lead qualifier...', model: 'haiku' });
// Pipeline: each agent's output feeds the next
const inspection = await inspector.ask(propertyBrief);
const estimate = await estimator.ask(inspection);
const outreach = await writer.ask(`${inspection}\n\n${estimate}`);
const qualification = await qualifier.ask(`${inspection}\n\n${estimate}\n\n${outreach}`);Four agents, four models, one pipeline. Inspector finds problems, Estimator prices fixes, Writer crafts a personalized letter, Qualifier scores the lead. See the full Curb Appeal example for the production-ready version.
Or three agents debating a stock:
const bull = new Agent('bull', 'Argue why AAPL is a buy. Cite data.', { model: 'grok' });
const bear = new Agent('bear', 'Argue why AAPL is overvalued. Cite risks.', { model: 'mini' });
const pm = new Agent('pm', 'Listen to both, then decide: BUY, HOLD, or SELL.', { model: 'sonnet' });
const bus = new Bus();
bus.on('research', ({ from, content }) => {
for (const a of [bull, bear, pm]) if (a.id !== from) a.queue(`${from}: ${content}`);
});
for (let i = 0; i < 3; i++) {
bus.emit('research', { from: 'bull', content: await bull.ask('Make your case.') });
bus.emit('research', { from: 'bear', content: await bear.ask('Respond.') });
}
const verdict = await pm.ask('What is your position? BUY, HOLD, or SELL.');| Example | Agents | Pattern | Key Capability |
|---|---|---|---|
| Curb Appeal | 4 specialists | Pipeline | Lead gen, structured deliverables, production pattern |
| Poker | 5 players | Adversarial | Private state, bluffing, game engine |
| Jury | 12 jurors | Consensus | Large groups, opinion shifts, persuasion |
| Lemonade | 3 kids | Cooperative | Shared resources, coordination tax, adaptation |
| Debate | 3 debaters | Competitive | Argumentation, voting |
Curb Appeal is the most practical — it shows a complete revenue-generating pipeline using public data and specialized agents.
Those are platforms with their own opinions about orchestration, persistence, and agent lifecycles.
Chorus is a library. No runtime, no server, no config files. You write the engine loop. You decide the turn order. You control what agents see and when. Chorus gives you reliable plumbing — message queues, event routing, append-only logs, and a provider registry.
Platforms give you agents that work their way. Chorus gives you primitives to build agents that work your way.
A message queue with an LLM consumer.
const agent = new Agent({
id: 'analyst',
systemPrompt: 'You are a senior equity research analyst.',
model: 'sonnet',
dataDir: 'data',
timeline: tl,
});
// One-shot
const response = await agent.ask('What is your view on AAPL?');
// Multi-message: accumulate context, then flush
agent.queue('AAPL revenue: $94.9B (+5% YoY)');
agent.queue('MSFT revenue: $65.6B (+16% YoY)');
const comparison = await agent.send();Core methods:
ask(msg)— queue + send in one call (most common)queue(msg)— add message without calling LLMsend()— flush queue and get responsepin(msg)— permanent context that survives compactioncompact(fn, keep?)— you control summarizationreset()— clear history to system prompt (for pipeline agents)
Subclass for custom behavior:
class ResearchAnalyst extends Agent {
async onMessage(msg) {
this.queue(msg);
if (msg.startsWith('analyze:')) {
const response = await this.send();
this.bus.emit('research', { from: this.id, content: response });
}
}
}Hooks — intercept the lifecycle:
| Hook | When | Receives | Return |
|---|---|---|---|
beforeSend |
Before LLM call | messages[] |
Modified messages |
afterSend |
After LLM responds | text |
Modified text |
onError |
Provider throws | Error |
Fallback string, or rethrow |
governance |
Before LLM call | { agent, model, messages, opts, usage } |
Throw to block |
beforeQueue |
Every queue() call |
content |
Modified content, or null to suppress |
Pub/sub event routing (~50 lines):
const bus = new Bus();
bus.on('research', (msg) => console.log(`${msg.from}: ${msg.content}`));
bus.on('*', (event, data) => console.log(`[${event}]`, data));
bus.once('done', () => console.log('finished'));
bus.emit('research', { from: 'bull', content: 'AAPL looks strong' });const tl = new Timeline('data/timeline.jsonl');
tl.emit('decision', { agent: 'pm', verdict: 'BUY' });
tl.byType('decision'); tl.byAgent('pm'); tl.last(10);
const session = new Session('data/state.json');
session.update(s => ({ ...s, round: s.round + 1 }));
session.log({ event: 'rebalance', reason: 'Bull thesis accepted' });Budget caps, rate limits, content filters — applied across all agents:
const gov = new Governance({ audit: new Timeline('data/audit.jsonl') });
gov.budget({ maxCostUsd: 5, costPerInputToken: 0.003/1000, costPerOutputToken: 0.015/1000 });
gov.rateLimit({ maxCallsPerMinute: 20 });
gov.filter((content) => content.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN REDACTED]'));
gov.apply(inspector, estimator, writer, qualifier);Providers — register once, reference by alias:
await loadProviders({
anthropic: { apiKey: '...', models: { sonnet: 'claude-sonnet-4-6' }, enabled: ['sonnet'] },
xai: { apiKey: '...', models: { grok: 'grok-4.20-0309-non-reasoning' }, enabled: ['grok'] },
});Add your own by extending BaseProvider (Ollama, Azure, local models).
Persistence — everything defaults to JSONL on disk. Swap in Postgres, Kafka, Redis via the 5-method Store interface:
class PostgresStore extends Store {
append(line) { db.query(`INSERT INTO ${this.table} (data) VALUES ($1)`, [line]); }
read() { return db.query(`SELECT data FROM ${this.table} ORDER BY id`).rows.map(r => r.data); }
write(lines) { /* ... */ }
exists() { /* ... */ }
clear() { /* ... */ }
}Your existing stack:
| You have | Chorus gives you |
|---|---|
| Datadog / Prometheus | afterSend hook — emit tokens, latency, cost per call |
| Postgres / Redis / Kafka | Store interface — 5 methods for full persistence swap |
| Airflow / Temporal | Session JSON checkpoints between pipeline steps |
| ELK / Splunk | Timeline JSONL ships as-is — one JSON event per line |
The framework doesn't enforce architecture. These emerge from combining the primitives:
- Pipeline — Inspector → Estimator → Writer → Qualifier (Curb Appeal)
- Generator → Evaluator — draft, critique, iterate
- Planner → Workers — decompose tasks, synthesize results
- Social Simulation — debate, negotiation, consensus (Jury, Lemonade)
- Lead generation and automated outreach
- Investment research and debate systems
- Product QA with persona agents
- Risk and scenario simulation
- Regulatory compliance auditing
- Training data generation
Chorus stays deliberately thin. Read DESIGN.md for the full rationale. See MODEL_NOTES.md for observations on how different LLMs behave in multi-agent systems.
- Agent = queue + LLM. Everything else is your decision.
- Thin middleware. Bus is 50 lines. Logic lives in your engine loop.
- Append-only. Conversations grow as JSONL. Full history always recoverable.
- Provider-agnostic. Switch models by changing a string.
- Everything is a file. Inspect with
cat. Debug by reading JSONL. Or swap in any Store. - Zero dependencies. Native
fetch,fs,crypto. No SDK lock-in. - Parallel by default. Each agent owns its state. Run N concurrently.
Made with curiosity and a healthy respect for tokens.