Skip to main content

Reactive Blackboard

The Reactive Blackboard is a stigmergic multi-agent coordination pattern. Actors declare which entry topics they watch, and when a matching entry is written, their body block runs automatically in the background. There is no controller, no loop, no central decision point. Coordination is emergent: adding a new actor requires zero changes to existing actors.

Use it when:

  • You have event-driven or continuous-monitoring problems
  • Agents react independently to shared state changes
  • You want decoupled coordination without a central orchestrator
  • New agents should be pluggable without modifying existing ones

If you need a controller that reads shared state and decides which specialist to invoke next, use the Blackboard pattern instead. Both patterns are siblings under the same composable patterns library.

Block composition

entry input
→ append entry to resource
→ forEachBackground(matched actors → actor.body)
↑ writer continues immediately
↓ each matched actor runs in background

The entire propagation graph lives inside FSD's flow tree. Tracing, heartbeats, cancellation, and replay all work without any external runtime.

Basic usage

import { reactiveBlackboard, actor, mesh } from "@flow-state-dev/patterns/reactive-blackboard";
import { handler } from "@flow-state-dev/core";
import { z } from "zod";

// 1. Define your entry schema
const entrySchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("observation"), topic: z.string(), body: z.any() }),
z.object({ type: z.literal("event"), topic: z.string(), body: z.any() }),
]);

// 2. Create the blackboard
const rb = reactiveBlackboard({ name: "feedback", entries: entrySchema });

// 3. Define actors
const slackMonitor = actor({
name: "slack-monitor",
watch: ["observation:slack.*"],
body: handler({
name: "slack-handler",
inputSchema: z.any(),
outputSchema: z.any(),
execute: (entry) => {
console.log("Slack observation:", entry.body);
return { processed: true };
},
}),
});

const alertWatcher = actor({
name: "alert-watcher",
watch: ["event:alert.**"],
body: handler({
name: "alert-handler",
inputSchema: z.any(),
outputSchema: z.any(),
execute: (entry) => {
console.log("Alert:", entry.body);
return { alerted: true };
},
}),
});

// 4. Wire everything together
const feedback = mesh({
name: "feedback",
blackboard: rb,
actors: [slackMonitor, alertWatcher],
});

Use feedback.emit in any sequencer to write entries with automatic fan-out:

import { sequencer } from "@flow-state-dev/core";

const pipeline = sequencer({ name: "main" })
.then(someProcessingBlock)
.then(feedback.emit); // Appends entry + fans out to matching actors

Config reference

reactiveBlackboard(config)

Creates the entry resource.

reactiveBlackboard({
name: string; // Instance name
entries: ZodTypeAny; // Schema for entry objects
});
// Returns: { blackboard: DefinedResource }

actor(config)

Creates an actor descriptor. A plain value, not a class.

actor({
name: string; // Unique actor name
watch: string[]; // Glob patterns over `${type}:${topic}`
body: BlockDefinition; // Any block kind
});
// Returns: Actor (frozen)

mesh(config)

Wires actors to a blackboard. Returns the configured emit block.

mesh({
name: string;
blackboard: { blackboard: DefinedResource }; // From reactiveBlackboard()
actors: Actor[];
concurrency?: number; // Default: 16
});
// Returns: { emit, blackboard, actors }

Topic matching

Patterns match against ${type}:${topic} using glob syntax:

PatternMatchesDoesn't match
observation:*observation:slackobservation:slack.msg
observation:**observation:slack.msg.editevent:slack
*:slackobservation:slack, event:slackobservation:slack.msg
observation:slack.*observation:slack.messageobservation:slack.a.b
**everything--

* matches a single segment (between : or .). ** matches any number of segments.

Two-tier reactive/deliberative

The reactive vs. deliberative split is a user-land pattern, not a framework concept. Put a router at the top of an actor's body:

const feedbackMonitor = actor({
name: "feedback_monitor",
watch: ["observation:slack.message"],
body: sequencer({
name: "monitor-pipeline",
})
.then(router({
name: "classify",
inputSchema: z.any(),
outputSchema: z.any(),
routes: [cheapHandler, expensiveGenerator],
execute: (input) => {
const text = input.body?.text ?? "";
return /crash|broken|urgent/i.test(text)
? expensiveGenerator
: cheapHandler;
},
})),
});

Actors that don't need the split just use a handler or generator directly.

Failure isolation

Each actor's body runs as an independent background sidechain. If one actor throws, the others continue. The writer's flow is never affected by actor failures. Failed dispatches are logged but don't propagate.

Exported API

// Factories
import { reactiveBlackboard, actor, mesh } from "@flow-state-dev/patterns/reactive-blackboard";

// Schemas (for remixing)
import { reactiveBlackboardStateSchema, emitControlSchema } from "@flow-state-dev/patterns/reactive-blackboard";

// Utilities
import { matchTopic, compilePattern } from "@flow-state-dev/patterns/reactive-blackboard";

// Helper blocks (for remixing)
import { createAppendEntry } from "@flow-state-dev/patterns/reactive-blackboard";

When to use Reactive Blackboard vs. Blackboard

Reactive Blackboard (this pattern): No controller. Actors subscribe to topics and react when matching entries are written. Coordination is emergent. Best for event-driven systems, continuous monitoring, and broadcast/notification scenarios where you want agents to react independently.

Blackboard: Controller-driven. An LLM controller reads shared state and picks which specialist to invoke next, in a loop. Best for incremental synthesis, directed problem-solving, and tasks where a "what's next" decision is needed on each iteration.

See also

  • Blackboard -- the controller-driven sibling pattern
  • Coordinator -- single-pass fan-out (use when tasks are known upfront)
  • Supervisor -- fan-out with review loop (use when quality review is needed)