DEV Community

Cover image for Building infection-protocol-lab: A Full-Stack Multi-Agent Simulation with TypeScript, LangChain, and ReactFlow
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building infection-protocol-lab: A Full-Stack Multi-Agent Simulation with TypeScript, LangChain, and ReactFlow

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

High-Level Architecture

There are three big layers:

  1. Simulation engine
  2. Transport layer
  3. 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[];
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.`;
Enter fullscreen mode Exit fullscreen mode

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:

  1. pairs agents using suspicion-aware matching
  2. runs bilateral interactions
  3. updates beliefs
  4. resolves infection spread
  5. lets the doctor act
  6. applies natural cures
  7. triggers periodic broadcasts
  8. evolves strategies
  9. 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
  });
}
Enter fullscreen mode Exit fullscreen mode

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)