infection-protocol-lab started as a question: what happens when autonomous LLM agents live inside a social system with incomplete information, hidden contagion, evolving strategy, and role-based asymmetry?
This project answers that question as a runnable local app.
It combines:
- a TypeScript simulation engine
- a LangChain-powered agent layer
- an Express + WebSocket backend
- a ReactFlow live visualization
- a benchmark playground for repeated experiments
This post walks through the architecture, tradeoffs, and code patterns behind the system.
Why Build This?
Most LLM demos are linear:
- send prompt
- receive answer
- end interaction
But many interesting system behaviors only appear when agents:
- interact repeatedly
- hold imperfect beliefs
- react to hidden state
- adapt their own strategy over time
That is where simulation gets interesting. infection-protocol-lab frames that environment around a hidden infection protocol:
- some agents start infected
- others do not know who is infected
- infection spreads through interaction
- one agent is a doctor
- infected agents can recover
- everyone is trying to survive and infer risk
High-Level Architecture
There are three big layers:
- Simulation engine
- Transport layer
- Visualization and benchmark UI
The Core Data Model
The backend defines a compact but expressive agent model:
export type AgentState = "healthy" | "infected" | "immune";
export type AgentRole = "normal" | "doctor";
export interface AgentSnapshot {
id: string;
state: AgentState;
role: AgentRole;
strategy: string;
beliefs: Record<string, number>;
riskTolerance: number;
memory: AgentMemoryEntry[];
}
This shape is important because it balances:
- enough structure for simulation logic
- enough context for LLM prompts
- enough simplicity for UI rendering
The LLM Layer
The LLM wrapper uses LangChain’s ChatOpenAI, but it also includes a graceful fallback so the app still runs without an API key.
export class LLMClient {
async generateResponse(prompt: string): Promise<string> {
if (!this.client) {
return this.fallback(prompt);
}
try {
const response = await this.client.invoke(prompt);
return typeof response.content === "string"
? response.content
: response.content.map((part) => ("text" in part ? part.text : "")).join(" ").trim();
} catch {
return this.fallback(prompt);
}
}
}
That fallback matters for developer ergonomics. It keeps the project demoable even when:
- you do not have an API key handy
- you are offline
- a model call fails temporarily
Agent Prompts as Behavioral Interfaces
Each agent is not just a data object. It is a prompt-shaped actor.
Here is the conversation prompt:
const prompt = `You are Agent ${this.id}.
Strategy:
${this.strategy}
You are interacting with Agent ${otherAgent.id}.
Infection spreads via interaction, but identities are hidden.
Goals:
- survive
- infer who is infected
- influence others
Respond in <=20 words.`;
This is a useful pattern for multi-agent apps:
- keep prompts role-specific
- keep them short
- make the output constrained
- preserve enough room for emergent variation
The same pattern is reused for:
- belief updates
- strategy evolution
- broadcast generation
Simulation Loop
The engine owns the authoritative simulation state. Each round:
- pairs agents using suspicion-aware matching
- runs bilateral interactions
- updates beliefs
- resolves infection spread
- lets the doctor act
- applies natural cures
- triggers periodic broadcasts
- evolves strategies
- emits events and a round snapshot
private async runRound(onEvent?: EventHandler): Promise<void> {
const pairs = createSmartPairs(this.agents, this.random);
for (const [first, second] of pairs) {
const [firstMessage, secondMessage] = await Promise.all([first.talk(second), second.talk(first)]);
await Promise.all([
first.updateBeliefs(second.id, secondMessage),
second.updateBeliefs(first.id, firstMessage)
]);
this.tryInfection(first, second, onEvent);
this.tryInfection(second, first, onEvent);
}
await this.performDoctorActions(onEvent);
this.performNaturalCures(onEvent);
await this.performBroadcast(onEvent);
await this.performEvolution(onEvent);
}
Why Smart Pairing Matters
Random pairing is easy, but it throws away one of the more interesting social signals: choice.
In this project, agents prefer low-suspicion partners, with randomness mixed in. That creates emergent structure:
- isolated suspicious agents
- safer trust clusters
- delayed or accelerated spread dynamics
It also makes beliefs operational instead of decorative.
WebSocket Streaming
The frontend is event-driven. Instead of polling constantly, the backend pushes:
- live interaction edges
- infection transitions
- cure events
- broadcast overlays
- strategy updates
- round summaries
That decision keeps the interface feeling alive and makes the system easier to reason about during a run.
ReactFlow as a Simulation Surface
ReactFlow is usually used for node editors or DAGs, but it works well as a dynamic social graph when you:
- represent each agent as a node
- use temporary animated edges for interactions
- color nodes by state
- encode roles visually
The frontend store converts backend events into graph state:
if (event.type === "interaction" && typeof event.from === "string" && typeof event.to === "string") {
const edgeId = `${event.from}-${event.to}-${event.timestamp}`;
nextState.edges = addEdge(
{
id: edgeId,
source: event.from,
target: event.to,
animated: true,
style: { stroke: "#38bdf8", strokeWidth: 2 }
},
state.edges
).slice(-24);
}
We also made those edges fade out after a few seconds so the graph communicates current activity rather than just historical clutter.
Benchmark Mode
The benchmark runner is what turns this from a visual toy into a practical experimentation harness.
Instead of one live simulation, you can run many:
for (let index = 0; index < runCount; index += 1) {
const config = mergeConfig({
...request.config,
seed: `${request.config.seed || "benchmark"}-${index + 1}`
});
const engine = new SimulationEngine(config);
const result = await engine.runToCompletion(false);
runs.push({
runId: result.runId,
seed: config.seed,
config,
metrics: result.metrics,
finalAgents: result.agents
});
}
That gives you aggregate metrics like:
- survival rate
- infection spread speed
- average trust score
- strategy diversity
- immune rate
This is especially useful for comparing:
- prompting styles
- infection parameters
- round counts
- different OpenAI-compatible models
Pixel UI as Product Framing
The UI was intentionally redesigned into a pixel-art operations deck rather than a generic analytics dashboard.
That was not just cosmetic.
The visual language reinforces what the app is:
- a simulation lab
- a control room
- a system to observe and experiment with
For a project like this, the visual frame matters because it communicates the right mental model before the user even clicks anything.
What Developers Can Learn From This Project
There are a few useful patterns here beyond the simulation itself:
1. Keep backend truth separate from frontend presentation
The backend owns simulation state. The frontend owns visualization state. That boundary keeps both sides simpler.
2. Design prompts like APIs
The prompt surface should be:
- minimal
- structured
- role-aware
- output-constrained
3. Build graceful fallback paths
The app still runs without an API key. That makes it dramatically easier to share and iterate on.
4. Make metrics first-class
If you are running experiments, the benchmark layer should not be an afterthought.
Future Directions
If I were extending this next, I would add:
- replay mode with a round timeline
- richer trust visualizations
- more agent roles
- persistent run history
- model-vs-model tournament mode
- editable prompts from the UI
- exportable benchmark results
infection-protocol-lab is a compact but expressive example of what happens when you treat LLM agents as participants in a system instead of one-off answer generators.
That shift, from response generation to interactive simulation, is where a lot of the interesting engineering starts.
Github Repo: https://github.com/harishkotra/infection-protocol-lab

Top comments (0)