The blackboard pattern for multi-agent memory.

A design pattern from 1970s speech recognition turns out to be the cleanest way for several agents to collaborate without knowing about each other.

Put two agents in a room and they need a way to share what they have learned. The tempting design is direct messages: the researcher agent calls the writer agent, which calls the critic agent, which calls back. Within a week you have a tangle where every agent imports every other, and adding a fourth agent means editing the other three.

The Blackboard pattern, invented for the Hearsay-II speech understanding system in the 1970s, solves it by removing the direct connections entirely. Agents never talk to each other. They read from and write to a shared structured memory - the blackboard - and a controller decides who acts next. It is a beautiful fit for multi-agent systems, and it is barely a hundred lines of Python.

The three parts

A blackboard system has exactly three pieces, and keeping them separate is the whole discipline:

  • The blackboard is shared state: a structured store of everything known so far.
  • The knowledge sources are the agents. Each one knows how to contribute if the blackboard is in a state it can improve.
  • The controller loops: it asks each knowledge source whether it can contribute, picks one, runs it, and repeats until the problem is solved.

Notice the agents never reference each other. A knowledge source’s entire world is the blackboard. That is what makes them independently writable, testable, and removable.

Shared state
Blackboard
A structured store every source reads from and writes to. The only channel.
Agents
Knowledge sources
Each declares what it needs and what it contributes. Blind to the others.
Loop
Controller
Picks a ready source, runs it, repeats until nobody can contribute.

Direct calls versus the blackboard

The difference is not cosmetic. It changes what happens when you add the fourth agent, or when the third one fails:

Direct message-passing

  • Every agent imports every other
  • Adding one means editing the rest
  • A failed call blocks its caller
  • Execution order is hardcoded

Shared blackboard

  • Agents only touch the store
  • New agents drop in with no rewiring
  • A stalled source just never fires
  • Order emerges from data dependencies

The blackboard

We start with the shared store. It is a dict with a little structure and, crucially, a record of what changed, so knowledge sources can react to contributions rather than re-scanning everything:

from dataclasses import dataclass, field
from typing import Any


@dataclass
class Blackboard:
    data: dict[str, Any] = field(default_factory=dict)
    log: list[str] = field(default_factory=list)

    def write(self, key: str, value: Any, by: str) -> None:
        self.data[key] = value
        self.log.append(f"{by} wrote {key!r}")

    def has(self, *keys: str) -> bool:
        return all(k in self.data for k in keys)

    def get(self, key: str, default=None):
        return self.data.get(key, default)

The by argument on write is not decoration. In a multi-agent system the first question when output is wrong is always “which agent wrote this?” The log answers it. Attribution is a debugging feature you install before you need it, because you will need it at the least convenient moment.

Knowledge sources

Each knowledge source declares a trigger condition and a contribution. The condition is “what does the blackboard need to contain for me to be useful,” and the contribution is “what I add”:

from dataclasses import dataclass


@dataclass
class Researcher:
    name: str = "researcher"

    def can_contribute(self, bb: Blackboard) -> bool:
        # I act when there is a question but no facts yet.
        return bb.has("question") and not bb.has("facts")

    def contribute(self, bb: Blackboard) -> None:
        q = bb.get("question")
        bb.write("facts", f"[facts gathered about: {q}]", by=self.name)


@dataclass
class Writer:
    name: str = "writer"

    def can_contribute(self, bb: Blackboard) -> bool:
        return bb.has("facts") and not bb.has("draft")

    def contribute(self, bb: Blackboard) -> None:
        bb.write("draft", f"Draft using {bb.get('facts')}", by=self.name)


@dataclass
class Critic:
    name: str = "critic"

    def can_contribute(self, bb: Blackboard) -> bool:
        return bb.has("draft") and not bb.has("final")

    def contribute(self, bb: Blackboard) -> None:
        bb.write("final", f"Polished: {bb.get('draft')}", by=self.name)

Read the can_contribute methods as a dependency graph written sideways. The researcher fires when there is a question and no facts. The writer fires when facts exist but no draft. The critic fires when a draft exists but nothing final. Nobody encoded the order “research, then write, then critique.” The order emerged from the data dependencies.

Let ordering emerge from preconditions. Hardcoding “step 1, step 2, step 3” makes the sequence brittle. When each agent instead declares what state it needs, the controller derives a valid order for free - and automatically handles new agents inserted anywhere in the chain.

The controller

The controller is the engine. It repeatedly finds a knowledge source that can act and runs it, stopping when no one can contribute (the problem is either solved or stuck). This is the same bounded-loop discipline as the agent loop, and for the same reason: unbounded loops that call models are how you generate a surprise bill.

def run_blackboard(
    sources: list,
    bb: Blackboard,
    max_rounds: int = 20,
) -> Blackboard:
    for _ in range(max_rounds):
        ready = [s for s in sources if s.can_contribute(bb)]
        if not ready:
            break                      # quiescence: nobody can help
        ready[0].contribute(bb)        # simplest control strategy: first ready
    return bb

The line ready[0] is where control strategy lives, and it is a genuine design lever. “First ready” is the simplest possible policy. You could instead score sources by expected value and pick the highest, run all ready sources in parallel, or ask a model which contribution matters most right now.

Running it

Seed the blackboard with a question and let it run. Step the controller below and watch the store fill in: the researcher fires first because it is the only one whose preconditions hold, then the writer, then the critic. The order was never written down anywhere.

Figure - The blackboard fills in as sources fire
seeded with: question
The controller repeatedly runs the first source whose preconditions the blackboard satisfies. Nobody encoded the order - it emerges from what each source reads and writes. The sources never call each other; they only touch the shared store.

In code, seed the blackboard with a question and let it run. Watch the log to see the emergent pipeline:

bb = Blackboard()
bb.write("question", "Why do agents need shared memory?", by="user")

result = run_blackboard([Critic(), Writer(), Researcher()], bb)

print(result.get("final"))
# Polished: Draft using [facts gathered about: Why do agents need shared memory?]

for line in result.log:
    print(line)
# user wrote 'question'
# researcher wrote 'facts'
# writer wrote 'draft'
# critic wrote 'final'

Look closely at the input: we passed the sources as [Critic(), Writer(), Researcher()], in reverse of the order they actually ran. It did not matter. The execution order came from the blackboard’s state, not from the list order. That decoupling of declaration order from execution order is exactly what direct message-passing cannot give you.

Where this pays off

The blackboard shines precisely when a workflow is not a straight line. A research task might loop: the critic finds the draft weak, deletes final, and writes a needs_more_research flag that re-triggers the researcher. Because sources only read state and preconditions, that cycle needs no new wiring. You add a source that reacts to needs_more_research, and the loop closes itself.

It also degrades gracefully under partial failure. If the writer errors out and never produces a draft, the critic simply never fires, and the log shows exactly where the chain stalled. No agent is waiting on a direct call that never returned, because there were no direct calls.

The single agent was a loop over a model. The multi-agent system is a loop over agents. In both cases the intelligence is not hidden in some clever central brain - it is distributed into small pieces that each do one legible thing, coordinated by a loop simple enough to hold in your head.

Work with us

Production AI, with guardrails.

Start with a fixed-scope AI Workflow Audit. We map the opportunity and quote a build.

Start a Discovery Sprint