Whiteboards & Real-Time Data Flow

In most distributed systems, “who talks to whom” is baked into code: a service calls a client library, which calls an endpoint, which knows a hostname. Psyclone AIOS inverts that. Modules post typed Data Messages onto the bus and subscribe to the types they care about, and Whiteboards — the system’s shared short-term memory — keep those messages so other components can query them later. Nothing holds a reference to anything else. If you are building a real-time voice AI system, a robot, or an industrial control loop where perception, reasoning and actuation all run at different speeds, this is the layer that lets them share data without waiting on each other.

Producers and consumers are decoupled by message type; Whiteboards retain messages for later query.
Producers and consumers are decoupled by message type; Whiteboards retain messages for later query.

Why it matters

The failure mode this removes is the hard-wired call graph. Once module A calls module B directly, the two are welded together: B cannot be restarted, replaced, moved to another machine, or fed from a recording without changing A. In a system with thirty components and a hard latency budget, that coupling is what makes every change expensive and every incident hard to reproduce. Psyclone’s routing lives in the PsySpec XML, not in the modules — crank code refers only to trigger and post names, so re-routing a module from input.audio.raw to input.audio.smoothed is a one-line spec edit with no recompilation.

The second problem is memory. A plain message queue is amnesiac: a module reacts to a message and forgets it. Real systems constantly need recent history — the last ten utterances, every detection in the past 200 ms, the position track that led to a decision. A Whiteboard is a catalog specialised for messages: every stored message is automatically indexed on its creation time, you can add keyed indexes over the message contents, and consumers pull results back with declared retrieves. That turns “recent history” from bespoke per-module buffering code into a platform primitive.

Commercially, the consequence is that the same dataflow serves development, operations and testing. Because the bus is the only integration surface, a wildcard Whiteboard is a non-intrusive debugging tap; PsyProbe browses any Whiteboard’s contents live; and a Replay Catalog can record a production session and replay it into a development system, where downstream modules cannot tell replayed messages from live ones. Repeatable tests and post-incident analysis come from the architecture rather than from extra instrumentation work.

How it works

Every message on the bus is a DataMessage: a header (type, sender, addressee, creation time, tags) plus any number of named user entries — strings, integers, floats, times, binary blobs and nested messages, each also available in array and map form. Timestamps are 64-bit with microsecond resolution and are synchronised across the machines of a distributed system, so creation times of messages produced on different nodes can be compared meaningfully — which is what makes sensor fusion and offline analysis tractable. Binary entries mean raw camera frames or audio buffers travel as-is, with no re-encoding, while PsyProbe still shows the surrounding header and text fields live.

Message types are dot-delimited strings that grow more specific left to right — input.video.raw, Robot.Status.Navigate.Done — and triggers subscribe to them exactly or by wildcard (input.audio.*). Delivery is a pipeline: a message must pass the type match, then the active-context check, then every trigger filter before a crank is woken. Filters cover haskey, string and numeric comparisons, and maxage as a trigger attribute in milliseconds, so a lagging consumer can simply skip stale input instead of building a backlog. Posts are guaranteed by default — the messaging layer holds a message until every matching subscriber has it — and guaranteed="no" marks high-rate, latest-value-wins streams best-effort. Signals are the fast lane: no subscription checking or routing, just a beat that wakes every subscriber, which is how simulation-style time-stepped systems are built. The core path is engineered as a sub-100µs message bus.

A DataMessage: header plus named user entries, with microsecond timestamps synchronised across nodes.
A DataMessage: header plus named user entries, with microsecond timestamps synchronised across nodes.

A Whiteboard sits in that flow as an ordinary component that happens to store what it receives. It fills either by subscribing with its own <trigger> elements, or by a post addressed straight at it with to="WB1" (which still reaches normal subscribers too). Retrieval is declared in the spec and executed from code: a <retrieve> names the source component and constraints — type, from, to, maxcount, maxage, tag, and an index key/keytype with start/end bounds — and the crank calls api->retrieve() or one of the four ranged calls (retrieveIntegerParam, retrieveFloatParam, retrieveStringParam, retrieveTimeParam). Each returns a QUERY_* status code, QUERY_SUCCESS being the one you want. For anything a retrieve cannot express, catalogs also accept free-form <query> declarations executed with api->queryCatalog().

Two mechanisms keep related messages identifiable across differing types. String tags are set on a post and filtered on a trigger, and propagate down the forward chain; propagating 32-bit integer tags are set by a component and automatically carried onto every consequent output of every receiving module and its descendants. Tags are also the join key for <triggergroup>, the declarative join operator: member messages sharing a tag are buffered on the node until the group completes, then delivered in one crank activation via waitForNewMessageGroup — so a multi-modal decision crank wakes once per complete set, not once per member.

How to use it

A Whiteboard with a keyed index, plus the retrieve that reads it back, is about six lines of PsySpec:

<whiteboard name="WB2" key="count" keytype="integer" maxcount="2000">
  <trigger name="Ball" type="ball.1" />
  <trigger name="Dialog" type="dialog.*" />
</whiteboard>

<retrieve name="r2" source="WB2" key="count" keytype="integer" />
// crank code: messages whose "count" entry is between 2 and 8,
// at most 4 of them, no older than 100 ms
std::list<DataMessage*> msgs;
uint8 status = api->retrieveIntegerParam(msgs, "r2", 2, 8, 4, 100000);
if (status == QUERY_SUCCESS) { /* ... */ }

The steps an engineer actually takes:

  1. Decide the message types and their dot-notation namespaces first — they are the system’s public interface, and everything else routes off them.
  2. Declare the Whiteboard, subscribing by type (wildcards included) or letting producers address it with to=. Bound it with maxcount / maxsize.
  3. Add a key / keytype index if consumers will query by content rather than by recency; repeat the pair, or use <key> children, for more than one index.
  4. Declare a <retrieve> per access pattern and call it by name from the crank; check the QUERY_* status rather than assuming results.
  5. Watch it in PsyProbe: every Whiteboard gets a Stored Messages tab with live filtering, message count, size and age, alongside the usual Performance, Activity and Subscriptions tabs.
  6. When you need the stream to survive the process, add a Replay Catalog (type="ReplayCatalog") with a root directory and caps, then replay it elsewhere by swapping its <trigger> entries for <post> entries of the same names.

When to use it / when not

Use it forCaveat or limit
Recent-history queries: last N messages, or messages whose content key falls in a rangeWhiteboard contents are in-memory. The root= persistence attribute is currently parsed but not acted on — treat it as a stub and use a Replay Catalog for durable capture.
Non-intrusive debugging taps — a wildcard Whiteboard records traffic without touching the modules that produce itUnbounded subscriptions grow memory. Always set maxcount or maxsize.
Decoupling producers from consumers so modules can be restarted, replaced or moved between nodesMessages matching no subscription are discarded unless the post sets a ttl.
Precise age windows on retrievalUnit mismatch: the ranged API calls take maxage in microseconds, while the XML maxage= attribute is stored ×1000 and its exact wall-clock unit is being confirmed against the engine build. Prefer the API call with an explicit microsecond value.
High-rate sensor streams where the newest value is the only one that mattersUse guaranteed="no" plus consumer-side maxage; that is backpressure behaviour, not ordering or acknowledgement.
Recording production dataflow for regression baselines and offline analysisThe on-disk format is internal binary, not an interchange format — read it back with a Replay Catalog rather than parsing files. Per-component <recording> / <playback> tags remain parsed-only stubs.

A Whiteboard is not a database and not a durable log. It is bounded short-term memory with a query language, optimised for a bus that must stay predictable under load. If you need long-term structured storage, use a DataCatalog or an external store fed by a module; if you need durable capture of the dataflow itself, record it.

Read the docs

Whiteboards and typed dataflow are the substrate the rest of the architecture is built on. See Global Contexts for how the active-context check gates delivery and switches whole behaviours at once, Heterogeneous Compute for what happens when producers and consumers sit on different machines and languages, and Ephemeral Compute for modules that come and go while the dataflow continues. The overview is on the Psyclone AIOS page.