DEV Community

Cover image for Reactive Agents, Typed Event Handlers, and Agent Swarms: What's New in Mozaik
Miodrag Vilotijević for JigJoy

Posted on • Originally published at jigjoy.ai

Reactive Agents, Typed Event Handlers, and Agent Swarms: What's New in Mozaik

When we released Mozaik v3, we introduced an event-based architecture where participants emit, observe, and react to typed context items inside a shared agentic environment. Since then, the framework has kept moving — and the way we think and talk about it has moved with it.

This post is a tour of what's new. The headline shift is conceptual: Mozaik is now framed around reactive agents — collaborative agents that adapt to their environment and to each other in real time. Underneath that frame, the API has gotten smaller, clearer, and easier to read.


From Non-Blocking to Reactive

Mozaik agents have always been non-blocking. That's still true — capability methods stream events as they're produced, so a slow inference or tool call never holds up other participants. But "non-blocking" describes a property, not a way of thinking.

The way we now think about Mozaik agents is reactive: an agent declares which events it cares about and reacts to them as they arrive — messages from humans, function calls from its own model, reasoning traces from a peer agent, or tool outputs from somewhere else in the environment. Behavior emerges from how agents react, not from a central controller deciding whose turn it is.

A reactive agent is event-driven, collaborative, and adapts to its environment and to other agents in real time.

This shift in framing pays off in design. Once an agent is a thing that reacts, adding behavior to a system stops being about editing a pipeline and becomes about joining another participant.


Typed Event Handlers

The biggest API change since 3.0 is on the participant side. In the original release, a participant exposed a single intercept point: onContextItem(source, item). You looked at the item, decided whether it came from you or someone else, and branched accordingly.

That worked, but it pushed a lot of dispatch logic into every agent. So we replaced the single intercept with a set of typed handlers, one per event kind:

  • onMessage
  • onFunctionCall
  • onFunctionCallOutput
  • onReasoning
  • onModelMessage

Each handler defaults to a no-op in the base class, so a reactive agent only writes the ones it actually cares about. The result is shorter, more declarative code — a reactive agent reads like a list of "when this happens, I do that," not like a switch statement.

Here is a full reactive agent built on top of BaseAgentParticipant. It records incoming messages, runs inference, executes its own tool calls, and keeps its local context in sync with what the model produces — all by overriding the handlers it cares about:

// reactive-agent.ts
import {
  BaseAgentParticipant,
  UserMessageItem,
  FunctionCallItem,
  FunctionCallOutputItem,
  ReasoningItem,
  ModelMessageItem,
  AgenticEnvironment,
  ModelContext,
  GenerativeModel,
  InputStream,
  InferenceRunner,
  FunctionCallRunner,
} from "@mozaik-ai/core"

export class ReactiveAgent extends BaseAgentParticipant {
  constructor(
    inputSource: InputStream,
    inferenceRunner: InferenceRunner,
    functionCallRunner: FunctionCallRunner,
    private readonly environment: AgenticEnvironment,
    private readonly context: ModelContext,
    private readonly model: GenerativeModel,
  ) {
    super(inputSource, inferenceRunner, functionCallRunner)
  }

  // A message from a human (or any other participant) — record it and think.
  async onMessage(message: string): Promise<void> {
    this.context.addContextItem(UserMessageItem.create(message))
    this.runInference(this.environment, this.context, this.model)
  }

  // The agent just produced a function call — execute it.
  async onFunctionCall(item: FunctionCallItem): Promise<void> {
    this.context.addContextItem(item)
    this.executeFunctionCall(this.environment, item)
  }

  // The tool just produced an output — feed it back and run inference again.
  async onFunctionCallOutput(item: FunctionCallOutputItem): Promise<void> {
    this.context.addContextItem(item)
    this.runInference(this.environment, this.context, this.model)
  }

  // Keep the local context in sync with model-emitted reasoning and replies.
  async onReasoning(item: ReasoningItem): Promise<void> {
    this.context.addContextItem(item)
  }

  async onModelMessage(item: ModelMessageItem): Promise<void> {
    this.context.addContextItem(item)
  }
}
Enter fullscreen mode Exit fullscreen mode

For comparison, the old style looked like this:

// Before: one handler, manual dispatch
async onContextItem(source: Participant, item: ContextItem): Promise<void> {
  if (source === this) return

  this.context.addContextItem(item)

  if (item instanceof UserMessageItem) {
    this.runInference(this.environment, this.context, this.model)
    return
  }

  if (item instanceof FunctionCallItem) {
    this.executeFunctionCall(this.environment, item)
    return
  }
}
Enter fullscreen mode Exit fullscreen mode

The Self vs. External Split

For every typed handler, there are two variants: a self handler that fires when this participant emits the event, and an external handler (prefixed with onExternal) that fires when another participant emits it.

Self (your emissions) External (everyone else)
onFunctionCall onExternalFunctionCall
onFunctionCallOutput onExternalFunctionCallOutput
onReasoning onExternalReasoning
onModelMessage onExternalModelMessage

The practical effect: a reactive agent can encode "act on my own outputs" separately from "observe others" — no more manual source === this checks.

  • Want to build a critic that reviews a peer agent's answers? Override onExternalModelMessage.
  • Want to feed your model's own tool calls back into a runner? Override onFunctionCall.

The two concerns stop bleeding into each other.

As a concrete example, here is a passive participant that only listens to external events. It does not run inference and does not execute tools — it just watches what other participants emit and logs it. Drop it into any environment and you have a live transcript:

// transcript-logger.ts
import {
  Participant,
  FunctionCallItem,
  FunctionCallOutputItem,
  ReasoningItem,
  ModelMessageItem,
} from "@mozaik-ai/core"

export class TranscriptLogger extends Participant {
  async onMessage(message: string): Promise<void> {
    console.log("[message]", message)
  }

  async onExternalFunctionCall(
    source: Participant,
    item: FunctionCallItem,
  ): Promise<void> {
    console.log(`[${source.constructor.name}] function_call`, item.toJSON())
  }

  async onExternalFunctionCallOutput(
    source: Participant,
    item: FunctionCallOutputItem,
  ): Promise<void> {
    console.log(
      `[${source.constructor.name}] function_call_output`,
      item.toJSON(),
    )
  }

  async onExternalReasoning(
    source: Participant,
    item: ReasoningItem,
  ): Promise<void> {
    console.log(`[${source.constructor.name}] reasoning`, item.toJSON())
  }

  async onExternalModelMessage(
    source: Participant,
    item: ModelMessageItem,
  ): Promise<void> {
    console.log(`[${source.constructor.name}] model_message`, item.toJSON())
  }
}
Enter fullscreen mode Exit fullscreen mode

Plain Messages on the Bus

Conversational text used to flow through the same context-item pipeline as everything else. It worked, but it was heavier than it needed to be. Now there are two clean lanes:

  1. Messages — plain strings, delivered via onMessage(message: string). Use them for what a human types, what one agent says to another, and any other free-form text.
  2. ContextItems — typed model internals (function calls, function call outputs, reasoning items, model messages), delivered via their dedicated handlers.

The participant side mirrors this: the old InputItemSource has become InputStream, an async iterable of strings. Each yielded string fans out via onMessage to every other participant. Inference and tool runners still produce typed ContextItems, exactly as before.

The benefit is mostly clarity. When you read a reactive agent now, you can tell at a glance whether a handler is reacting to "someone said something" or to "the model produced a tool call."

import { InputStream } from "@mozaik-ai/core"

// Async iterable of plain strings — each yield becomes onMessage for peers.
async function* humanInput(): InputStream {
  yield "Add rate limiting to all API endpoints"
  yield "Focus on the NestJS backend first"
}
Enter fullscreen mode Exit fullscreen mode

A Bigger Model Lineup

The default OpenAI provider now ships a wider model lineup. In addition to Gpt54, Gpt54Mini, and Gpt54Nano, there is now Gpt55. Same OpenAIResponses runtime, same OpenResponses-aligned types — pick the model that fits the job.

import { Gpt55, Gpt54Mini } from "@mozaik-ai/core"

// Heavier reasoning for planning; lighter model for review passes.
const planner = new Gpt55()
const reviewer = new Gpt54Mini()
Enter fullscreen mode Exit fullscreen mode

From One Agent to a Swarm

The reactive frame really earns its keep when more than one agent shares an environment. We've started calling these collaborative groups agent swarms: a handful of focused reactive agents — planner, executors, reviewer, and observers — all joining one AgenticEnvironment and reacting to each other's events.

Agent swarms — multiple reactive agents collaborating in one agentic environment

Agent swarms — multiple reactive agents collaborating in one agentic environment

The clearest production example is baro, a Claude agent orchestrator built on Mozaik. Ten specialized participants — planner, executors, reviewer, fixer, librarian, auditor, and more — work fully concurrently on the same goal, like a team collaborating in real time instead of a single agent doing everything alone.

Adding a new role to a swarm doesn't mean editing a central controller. It means writing one more reactive agent and join()ing it to the environment. Everything else keeps working.

import { AgenticEnvironment } from "@mozaik-ai/core"

const environment = new AgenticEnvironment()

planner.join(environment)
executor.join(environment)
critic.join(environment)
new TranscriptLogger().join(environment)

environment.start()
Enter fullscreen mode Exit fullscreen mode

Composing Intelligence

Each of these changes is small on its own. Together, they shift Mozaik further toward what we wanted from the start: a framework where intelligent behavior is something you compose, not something you orchestrate.

Change What it unlocks
Reactive agents Behavior from event handlers, not a central run() loop
Typed handlers Readable "when X, do Y" agents without dispatch boilerplate
Self vs. external Critics, observers, and actors coexist without source === this
Plain messages Conversational text separate from model-internal ContextItems
Agent swarms Many focused agents on one bus — see baro

Reactive agents make the building block explicit. Typed handlers make each agent easier to read. The self vs. external split keeps intent obvious. Plain messages keep the conversational lane clean. And the swarm pattern shows what falls out the other side: groups of agents that adapt to their environment and to each other in real time.


Get started

npm install @mozaik-ai/core
Enter fullscreen mode Exit fullscreen mode

Star on GitHub: github.com/jigjoy-ai/mozaik

Related: Mozaik 3.0 — Structured Context & the Agentic Environment ·

Read the full article on JigJoy: jigjoy.ai/blog/mozaik-reactive-agents

Top comments (0)