Situation handlers
Participants react by pairing a SituationSpecification with a SituationProcessor.
Participants react by registering situation handlers. The runtime publishes events; a handler is a pair of “when” and “then”:
SituationSpecification—isSatisfiedBy({ event, participant }). Compose with.and(),.or(), and.not().SituationProcessor—apply(context)is the reaction:runLoop,sendMessage, mutate runtime state, log, persist, …
flowchart LR
Human[Participant] -->|"sendMessage(text, senderId)"| Runtime(("Runtime"))
Agent[Participant] -->|"runLoop"| Runtime
Observer[Participant] -->|join| Runtime
Runtime -->|"SemanticEvent"| Human
Runtime -->|"SemanticEvent"| Agent
Runtime -->|"SemanticEvent"| Observer
Fan-out is synchronous and does not await processors, so a slow listener never blocks producers or other listeners. Participants start receiving events as soon as they join().
There are no built-in specifications — write the ones you need. Self versus others is a filter on producerId, not a second handler API.
import {
defineRuntime,
RuntimeState,
createAgent,
createHuman,
SituationSpecification,
Agent,
type SituationHandler,
type SituationContext,
} from '@mozaik-ai/core';
class AppState extends RuntimeState {}
const { initializeRuntime, join, sendMessage, runLoop } = defineRuntime<AppState>();
initializeRuntime({ state: new AppState() });
class WhenOthersSendAMessage extends SituationSpecification {
isSatisfiedBy({ event, participant }: SituationContext): boolean {
return event.type === 'message.sent' && event.producerId !== participant.getId();
}
}
const thinkOnMessage: SituationHandler = {
specification: new WhenOthersSendAMessage(),
processor: {
apply({ event, participant }) {
if (!(participant instanceof Agent)) return;
const { message } = event.payload as { message: string };
runLoop(participant.getId(), message, {
model: 'gpt-5.5',
context: participant.getMemory().getContext(),
tools: participant.getTools(),
});
},
},
};
const agent = createAgent({
name: 'Assistant',
capabilities: ['inference'],
instruction: 'You are a helpful teammate.',
tools: [],
handlers: [thinkOnMessage],
});
const human = createHuman({ name: 'User', capabilities: [], handlers: [] });
join(human);
join(agent);
sendMessage('Hello', human.getId());An observer is the same pattern with processors that only take side actions:
class WhenModelAnswers extends SituationSpecification {
isSatisfiedBy({ event }: SituationContext): boolean {
return event.type === 'model.answer';
}
}
const transcript: SituationHandler = {
specification: new WhenModelAnswers(),
processor: {
apply({ event }) {
console.log('[model.answer]', event.producerId, event.payload);
},
},
};
const observer = createHuman({ name: 'Transcript', capabilities: [], handlers: [transcript] });
join(observer);Composition
SituationSpecification is an abstract class. Implement isSatisfiedBy, then combine instances:
const whenPeerAnswers = new WhenModelAnswers().and(
new (class extends SituationSpecification {
isSatisfiedBy({ event, participant }: SituationContext): boolean {
return event.producerId !== participant.getId();
}
})(),
);.and(other), .or(other), and .not() return new specifications.
Three things to note
- “Act on my own outputs” versus “observe others” is a specification filter on
event.producerIdversusparticipant.getId()— not a separate handler API. - Do not
awaitrunLooporsendMessageinside a processor. They returnvoidand keep running in the background while the runtime keeps delivering events. - Behaviors compose by reaction, not orchestration. Add a second agent whose spec matches
model.answerand you get a critique loop. Add a transcript observer and you get a UI stream. Neither change touches the existing participants.
processor.apply may return void or Promise<void>. The runtime still does not await it, so a thrown error inside an async processor is not routed onto the bus.