Contents · 16 sections↓
- 01How do you connect AI agents with an event bus?
- 02What is a multi-agent event bus?
- 03When to use an event bus between agents (and when not to)
- 04What you need
- 05Step 1: Build an event bus and a message broker
- 06Step 2: Let an agent publish, and chain a second agent
- 07Step 3: Filter events by source and content
- 08Step 4: Chain agents on lifecycle events
- 09Step 5: Plan for full queues
- 10The complete example
- 11Running it in production
- 12LangGraph handoffs vs CrewAI Flows vs a Promptise event bus
- 13Common problems and fixes
- 14Limitations
- 15Frequently asked questions
- 16Next steps
When several AI agents work together, they need a way to hand work to each other. A multi-agent event bus does that with publish/subscribe: an agent publishes an event or a message on a topic, and every agent subscribed to it wakes up, without the sender knowing who listens. This guide explains how pub/sub between agents works, when it beats direct handoffs, and how to chain long-running agents on events with the Promptise Foundry agent runtime.
How do you connect AI agents with an event bus?
Run each agent as its own long-running process, subscribe it to event types or topics on a shared bus, and let producers publish instead of calling agents directly. In Promptise, pass your bus and broker to AgentRuntime(event_bus=..., broker=...), give each agent a TriggerConfig(type="event", event_type=...) or TriggerConfig(type="message", topic=...), let agents publish through a tool, and bridge the runtime's own lifecycle events onto the bus with EventBusSink.
Bring your own bus. The runtime ships none; any object with async subscribe and unsubscribe methods works, from a 40-line in-memory class to a Redis or NATS wrapper.
Select with `event_source`, `filter_expression` and topic patterns. Wildcards like incidents.*.high are matched by your broker.
Agents publish through tools. The model decides what to publish; your tool decides where it goes.
Lifecycle events chain too: a process.failed can wake a diagnosing agent.
Each trigger buffers 100 events. A burst beyond that is dropped with only a log warning.
[02]
What is a multi-agent event bus?
An event bus is a publish/subscribe channel. Publishers send events with a type (alert.raised) or messages on a topic (incidents.checkout.high). Subscribers register interest in types or topic patterns. The bus delivers each event to every matching subscriber. Publishers and subscribers never reference each other.
In a multi-agent system, the subscribers are agents. Compare that with the two other common ways agents cooperate:
Direct calls (a supervisor calls a worker agent as a tool, or one agent hands off to another): the caller waits for an answer, both run in the same request, and the caller must know the callee.
A shared workflow graph (LangGraph, CrewAI Flows): one run moves through agent nodes in an order the graph defines.
An event bus: each agent runs on its own, whenever its events arrive. Adding a subscriber doesn't change the publisher, a slow agent doesn't block the others, and one failing agent doesn't fail the rest.
The example in this guide has three producers and three agents:
monitoring ──alert.raised──▶ [event bus] ──▶ triage agent (event trigger, source=monitoring)
│ publish_incident tool
▼
[broker] incidents.<service>.<severity>
│ topic pattern incidents.*.high
▼
on-call agent ──▶ page_engineer tool
any agent's process ──process.failed──▶ EventBusSink ──▶ [event bus] ──▶ doctor agentThe runtime has two trigger types for this. An event trigger listens for one event type on an event bus and can require a source. A message trigger listens for one topic (or pattern) on a message broker. They differ in the interface they call, not in what they can carry; many teams use only one.
[03]
When to use an event bus between agents (and when not to)
Situation | Event bus | Direct call or graph |
|---|---|---|
Several agents react to the same thing | Yes: one publish, many subscribers | Caller must call each |
The next agent can run later (seconds, minutes) | Yes | Overkill |
Agents owned by different teams or deployed separately | Yes: they share only topic names | Tight coupling |
A user waits for one answer built by several agents | No | Yes: handoffs, supervisor, cross_agents |
Steps must happen in a strict order, or all or nothing | No | Yes: a workflow graph or a workflow engine |
You need delivery guarantees across crashes | Only with a durable bus | Use a durable queue or workflow engine |
Events are fire-and-forget. If an agent needs the result to continue, call the other agent directly. If the work can continue without it, publish.
[04]
What you need
Python 3.10 or later and Promptise Foundry 1.5.0 or later: pip install "promptise>=1.5.0".
An OpenAI API key for step 2 (gpt-5-mini; one run costs well under a cent). Steps 1, 4 and 5 need no key: they use the trigger classes directly or a small scripted model server (fake_llm.py).
About 20 minutes.
[05]
Step 1: Build an event bus and a message broker
The runtime calls a few methods and reads a few attributes. That is the whole contract:
Caller | Calls | Reads from the delivered object |
|---|---|---|
event trigger | await bus.subscribe(event_type, handler), await bus.unsubscribe(event_type, handler) | event_id, source, data |
message trigger | await broker.subscribe(topic, handler) returning an ID, await broker.unsubscribe(sub_id) | message_id, sender, content |
EventBusSink, escalations | await bus.emit(event_type, data) |
Missing attributes become empty values. Here is an in-memory version of both, with shell-style wildcards (* matches any characters, dots included):
"""An in-process event bus and message broker with the methods the runtime's triggers call.
EventTrigger calls await bus.subscribe(event_type, handler) / await bus.unsubscribe(event_type, handler)
MessageTrigger calls await broker.subscribe(topic, handler) -> id / await broker.unsubscribe(id)
Escalations and EventBusSink call await bus.emit(event_type, data)
Swap these for wrappers around Redis, NATS or Kafka when agents run in several programs.
"""
from __future__ import annotations
import fnmatch
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
@dataclass
class BusEvent:
"""What EventTrigger reads: event_id, source and data."""
data: dict[str, Any]
source: str = ""
event_id: str = field(default_factory=lambda: uuid4().hex)
@dataclass
class Message:
"""What MessageTrigger reads: message_id, sender and content."""
content: Any
sender: str = ""
message_id: str = field(default_factory=lambda: uuid4().hex)
class EventBus:
"""Event types may use shell-style wildcards when subscribing: "alert.*"."""
def __init__(self) -> None:
self._handlers: dict[str, list] = {}
self.log: list[str] = []
async def subscribe(self, event_type: str, handler) -> None:
self._handlers.setdefault(event_type, []).append(handler)
async def unsubscribe(self, event_type: str, handler) -> None:
self._handlers.get(event_type, []).remove(handler)
async def emit(self, event_type: str, data: dict[str, Any], source: str | None = None) -> None:
# Runtime events (EventBusSink) carry the process name as agent_id: use it as the source.
source = source if source is not None else str(data.get("agent_id") or "")
self.log.append(f"event {event_type} from {source or '?'}")
for pattern, handlers in list(self._handlers.items()):
if fnmatch.fnmatchcase(event_type, pattern):
for handler in list(handlers):
await handler(BusEvent(data=data, source=source))
class Broker:
"""Topics are dotted names; subscriptions may use wildcards: "incidents.*.high"."""
def __init__(self) -> None:
self._subs: dict[str, tuple[str, Any]] = {}
self.log: list[str] = []
async def subscribe(self, topic: str, handler) -> str:
sub_id = uuid4().hex
self._subs[sub_id] = (topic, handler)
return sub_id
async def unsubscribe(self, sub_id: str) -> None:
self._subs.pop(sub_id, None)
async def publish(self, topic: str, content: Any, sender: str = "") -> int:
# The trigger's payload names the *pattern* it subscribed to, so put the real topic
# into dict messages: the receiving agent can see where the message came from.
if isinstance(content, dict):
content = {"topic": topic, **content}
self.log.append(f"message {topic} from {sender or '?'}")
delivered = 0
for pattern, handler in list(self._subs.values()):
if fnmatch.fnmatchcase(topic, pattern):
await handler(Message(content=content, sender=sender))
delivered += 1
return deliveredTwo choices in this file matter later. emit uses the event's agent_id as the source when none is given, because events from the runtime carry the process name there (step 4). And publish adds the concrete topic to dict messages, because a message trigger's payload names the *pattern* it subscribed to, not the topic the message was sent on.
Plug both into the trigger classes to see what an agent will receive:
"""Step 1: plug the bus and broker into the two triggers and look at what they produce."""
import asyncio
from bus import Broker, EventBus
from promptise.runtime import EventTrigger, MessageTrigger
async def main() -> None:
bus, broker = EventBus(), Broker()
alerts = EventTrigger(bus, "alert.raised", source_filter="monitoring")
incidents = MessageTrigger(broker, "incidents.*.high")
for trigger in (alerts, incidents):
await trigger.start()
await bus.emit("alert.raised", {"service": "checkout", "error_rate": 0.31}, source="load-test")
await bus.emit("alert.raised", {"service": "checkout", "error_rate": 0.31}, source="monitoring")
await broker.publish("incidents.checkout.low", {"summary": "slow page"}, sender="triage")
await broker.publish("incidents.checkout.high", {"summary": "31% errors"}, sender="triage")
print("EventTrigger: ", (await alerts.wait_for_next()).payload)
print("MessageTrigger:", (await incidents.wait_for_next()).payload)
for trigger in (alerts, incidents):
await trigger.stop()
print("left over: ", trigger.trigger_id, trigger.drain_pending())
asyncio.run(main())EventTrigger: {'event_type': 'alert.raised', 'event_id': 'a9ee8b1aaef949cf94999b8ef76f8e9b', 'source': 'monitoring', 'data': {'service': 'checkout', 'error_rate': 0.31}}
MessageTrigger: {'topic': 'incidents.*.high', 'message_id': '36253a56c5dc4fb8bb5e459462679875', 'sender': 'triage', 'content': {'topic': 'incidents.checkout.high', 'summary': '31% errors'}}
left over: event-1ed78805 []
left over: message-9198f0a4 []The event trigger took only the alert from monitoring; the load-test alert didn't match source_filter and never reached its buffer. The message trigger took only the high incident. Its payload says topic: 'incidents.*.high' (the subscription), while the content says incidents.checkout.high (where it was published). drain_pending() returned nothing, so nothing else was buffered. Inside a process, source_filter is the event_source field of TriggerConfig.
[06]
Step 2: Let an agent publish, and chain a second agent
An agent publishes through a tool. The tool runs your code, so it decides the topic format, adds the sender, and can refuse topics the agent shouldn't use. Below, a triage agent wakes on monitoring alerts and publishes incidents; an on-call agent subscribes to incidents.*.high and pages an engineer. Both tool servers run in the same program, so their tools can reach the in-memory broker. A shared HookManager prints when each agent's run ends.
"""Chain two agents with events: monitoring -> triage agent -> broker topic -> on-call agent.
Needs OPENAI_API_KEY. The two tool servers run in this program, so their tools can
publish to the in-process broker; in production they would publish to Redis, NATS or Kafka.
"""
import asyncio
import logging
import time
from bus import Broker, EventBus
from promptise.mcp.server import MCPServer
from promptise.runtime import (
AgentRuntime, HookEvent, HookManager, ProcessConfig, TriggerConfig,
)
T0 = time.perf_counter()
bus, broker = EventBus(), Broker()
# -- Tools: the triage agent can publish, the on-call agent can page -------------------------
triage_tools = MCPServer(name="triage-tools")
@triage_tools.tool()
async def publish_incident(service: str, severity: str, summary: str) -> str:
"""Publish an incident for the other agents. severity is low, medium or high."""
topic = f"incidents.{service}.{severity}"
receivers = await broker.publish(
topic, {"service": service, "severity": severity, "summary": summary}, sender="triage")
return f"Published to {topic} ({receivers} subscriber(s))."
oncall_tools = MCPServer(name="oncall-tools")
@oncall_tools.tool()
async def page_engineer(service: str, message: str) -> str:
"""Page the on-call engineer of a service with a short message."""
print(f"{time.perf_counter() - T0:5.1f}s PAGE [{service}] {message}")
return "Paged."
# -- Agents ----------------------------------------------------------------------------------
TRIAGE = ProcessConfig(
model="openai:gpt-5-mini",
instructions=(
"You triage monitoring alerts. For each alert, call publish_incident once. "
"Severity: high if error_rate >= 0.10 or the service is down, medium if "
"error_rate >= 0.02, otherwise low. The summary is one sentence with the numbers."
),
servers={"tools": {"url": "http://127.0.0.1:8801/mcp"}},
triggers=[TriggerConfig(type="event", event_type="alert.raised", event_source="monitoring")],
)
ONCALL = ProcessConfig(
model="openai:gpt-5-mini",
instructions=(
"You are the on-call assistant. For each high-severity incident, call page_engineer "
"once with a message under 25 words: what is broken and the first thing to check."
),
servers={"tools": {"url": "http://127.0.0.1:8802/mcp"}},
triggers=[TriggerConfig(type="message", topic="incidents.*.high")],
)
async def main() -> None:
servers = [asyncio.create_task(triage_tools.run_async("http", port=8801)),
asyncio.create_task(oncall_tools.run_async("http", port=8802))]
await asyncio.sleep(1.0) # let both tool servers bind
logging.getLogger("uvicorn.access").disabled = True # keep the output readable
hooks = HookManager()
@hooks.on(HookEvent.TASK_COMPLETED)
async def done(ctx) -> None:
print(f"{time.perf_counter() - T0:5.1f}s {ctx.process_id:<7} run {ctx.data['outcome']}"
f" ({ctx.data['trigger_type']} trigger)")
runtime = AgentRuntime(event_bus=bus, broker=broker, hooks=hooks)
triage = await runtime.add_process("triage", TRIAGE)
oncall = await runtime.add_process("oncall", ONCALL)
await runtime.start_all()
await bus.emit("alert.raised", {"service": "checkout", "error_rate": 0.31,
"window": "5m"}, source="monitoring")
await bus.emit("alert.raised", {"service": "search", "p95_latency_ms": 870,
"error_rate": 0.004}, source="monitoring")
deadline = time.perf_counter() + 120
while time.perf_counter() < deadline:
high = sum(".high " in line for line in broker.log)
if triage.status()["invocation_count"] == 2 and oncall.status()["invocation_count"] == high:
break
await asyncio.sleep(0.2)
print("bus traffic:", *bus.log, *broker.log, sep="\n ")
await runtime.stop_all()
for task in servers:
task.cancel()
await asyncio.gather(*servers, return_exceptions=True)
asyncio.run(main())Run against gpt-5-mini (output trimmed: the two tool servers' start-up banners are removed):
…
8.6s triage run success (event trigger)
12.3s PAGE [checkout] Checkout service: 31% error rate. First check recent deploys and 5m logs for payment/backend API errors.
18.9s triage run success (event trigger)
22.5s oncall run success (message trigger)
bus traffic:
event alert.raised from monitoring
event alert.raised from monitoring
message incidents.checkout.high from triage
message incidents.search.low from triageFollow the chain. Monitoring emitted two alerts. The triage agent ran once per alert and called publish_incident each time: incidents.checkout.high for the 31% error rate and incidents.search.low for the latency alert. Only the high topic matched the on-call agent's pattern, so it ran once and paged about checkout. The search incident was published to a topic nobody listened to; the broker delivered it to zero subscribers, which is normal in pub/sub. Adding a "low-severity digest" agent later means subscribing it to incidents.*.low, without touching the triage agent.
Each agent runs as its own agent process with its own queue, so the on-call agent paged while the triage agent was still working on the second alert. The model decided the severity from the thresholds in the instructions; the code decided where that severity sends the incident.
[07]
Step 3: Filter events by source and content
The triggers above select by type, source and topic. For anything finer, add a filter_expression; it runs before the event is queued, so skipped events cost no model call. Event payloads wrap the producer's fields in data, message payloads in content:
TriggerConfig(type="event", event_type="alert.raised", event_source="monitoring",
filter_expression="payload.data.error_rate >= 0.02")
TriggerConfig(type="message", topic="incidents.*.high",
filter_expression="payload.content.service != 'sandbox'")event_source drops events inside the trigger and doesn't count them. Events a filter skips are counted in process.status()["filtered_count"]. The expression language, function filters and how to measure the savings are covered in AI agent event filters.
[08]
Step 4: Chain agents on lifecycle events
Agents can react to what happens to other agents, not only to what they publish. Every process emits process.started, process.failed, process.restarted, process.restart_exhausted, process.suspended and more through the runtime's event notifier. EventBusSink forwards the ones you choose to the bus with bus.emit(event_type, event_dict). Below, a doctor agent wakes whenever another process fails:
"""Step 4: wake a "doctor" agent whenever another agent's process fails."""
import asyncio
import json
import logging
import os
import re
os.environ["OPENAI_BASE_URL"] = "http://127.0.0.1:8794/v1" # the scripted model server
os.environ["OPENAI_API_KEY"] = "not-a-real-key"
logging.getLogger("promptise").setLevel(logging.CRITICAL)
from bus import EventBus
from fake_llm import FakeLLM
from promptise.events import EventBusSink, EventNotifier
from promptise.runtime import AgentRuntime, ProcessConfig, TriggerConfig, TriggerEvent
bus = EventBus()
notifier = EventNotifier(sinks=[EventBusSink(bus, events=["process.failed"])])
WORKER = ProcessConfig(model="openai:gpt-5-mini", instructions="You reconcile invoices.",
max_consecutive_failures=1)
DOCTOR = ProcessConfig(
model="openai:gpt-5-mini",
instructions="A process failed. Read the error and suggest the most likely cause.",
triggers=[TriggerConfig(
type="event",
event_type="process.failed",
filter_expression="source != 'doctor'", # never react to its own failures
)],
)
async def main() -> None:
llm = FakeLLM()
await llm.start()
llm.poison = {"INV-9"} # this invoice makes the worker's model call fail
llm.reply = "Likely cause: the model provider rejected the input (HTTP 400)."
runtime = AgentRuntime(event_bus=bus, event_notifier=notifier)
worker = await runtime.add_process("invoice-worker", WORKER)
doctor = await runtime.add_process("doctor", DOCTOR)
await runtime.start_all()
await worker.inject(TriggerEvent(trigger_id="manual", trigger_type="queue",
payload={"invoice": "INV-9"}))
await asyncio.sleep(1.5)
print("worker state:", worker.state.value, "| doctor runs:",
doctor.status()["invocation_count"])
print("bus:", bus.log)
block = re.search(r"<(untrusted-[\w-]+)>\n(.*)\n</\1>", llm.prompts[-1], re.S).group(2)
payload = json.loads(block) # what the doctor agent read
event = payload["data"] # the AgentEvent that EventBusSink put on the bus
print("doctor payload: source =", payload["source"], "| event_type =", event["event_type"])
print(" data =", {k: str(v)[:60] for k, v in event["data"].items()})
await runtime.stop_all()
await llm.stop()
asyncio.run(main())worker state: failed | doctor runs: 1
bus: ['event process.failed from invoice-worker']
doctor payload: source = invoice-worker | event_type = process.failed
data = {'process_name': 'invoice-worker', 'process_id': 'c77f206c-7ef5-4afa-adf7-340393ff8d64', 'reason': "max consecutive failures (1): GraphExecutionError: Graph 're", 'error': "GraphExecutionError: Graph 'react' failed at node 'reason': "}The invoice worker failed on a bad invoice and, with max_consecutive_failures=1, went to FAILED. The notifier emitted process.failed, the sink put it on the bus, and the doctor ran once with the event in its prompt. The payload nests twice: the trigger's data is the whole AgentEvent (with event_type, severity, agent_id), and that event's own data holds the process name, reason and error. A filter on the failing process therefore reads payload.data.data.process_name.
The filter_expression="source != 'doctor'" line prevents a feedback loop. Without it, a failure of the doctor itself would wake the doctor. Any agent that subscribes to lifecycle events of "all processes" needs a rule like this.
[09]
Step 5: Plan for full queues
Each event and message trigger has a buffer of 100 events. The process's listener moves events from that buffer to the process queue (1,000 events), and workers run them. What happens in a burst depends on whether the producer gives the listener a chance to run:
"""Step 5: what happens when events arrive faster than an agent can take them."""
import asyncio
import logging
import os
os.environ["OPENAI_BASE_URL"] = "http://127.0.0.1:8794/v1"
os.environ["OPENAI_API_KEY"] = "not-a-real-key"
logging.getLogger("promptise").setLevel(logging.CRITICAL)
from bus import EventBus
from fake_llm import FakeLLM
from promptise.runtime import AgentRuntime, ProcessConfig, TriggerConfig
class CountWarnings(logging.Handler):
count = 0
def emit(self, record: logging.LogRecord) -> None:
if "queue full" in record.getMessage():
CountWarnings.count += 1
trigger_log = logging.getLogger("promptise.runtime.triggers.event")
trigger_log.setLevel(logging.WARNING)
trigger_log.addHandler(CountWarnings())
trigger_log.propagate = False
async def main() -> None:
llm = FakeLLM()
await llm.start()
llm.delay = 0.05 # each run takes a moment
bus = EventBus()
runtime = AgentRuntime(event_bus=bus)
process = await runtime.add_process("order-agent", ProcessConfig(
model="openai:gpt-5-mini", instructions="Confirm orders.",
triggers=[TriggerConfig(type="event", event_type="order.created")]))
await runtime.start_all()
async def burst(label: str, yield_between: bool) -> None:
before, dropped_before = process.status()["invocation_count"], CountWarnings.count
for n in range(150):
await bus.emit("order.created", {"order": n}, source="shop")
if yield_between:
await asyncio.sleep(0) # give the trigger's listener a turn
await asyncio.sleep(0.2)
s = process.status()
accepted = s["invocation_count"] - before + s["queue_size"] + 1 # +1 running
print(f"{label:<28} sent=150 accepted={accepted} queued={s['queue_size']}"
f" dropped={CountWarnings.count - dropped_before}")
await burst("150 events, no yielding", yield_between=False)
await asyncio.sleep(10) # let the agent catch up
await burst("150 events, yield each time", yield_between=True)
await runtime.stop_all()
print("dead letters after stop:", len(process.dead_letters),
{d["reason"] for d in process.dead_letters})
await llm.stop()
asyncio.run(main())150 events, no yielding sent=150 accepted=100 queued=96 dropped=50
150 events, yield each time sent=150 accepted=150 queued=146 dropped=0
dead letters after stop: 100 {'process stopped'}In the first burst, the producer emitted 150 events in a tight loop without yielding to the event loop. The listener never ran in between, the trigger's buffer filled at 100, and the last 50 events were dropped. The only trace is a queue full, dropping event warning per event on the promptise.runtime.triggers.event logger; nothing shows up in status() or the dead letters. In the second burst, the producer yielded after each event, the listener kept moving events to the 1,000-slot process queue, and all 150 were accepted.
The stop at the end dead-lettered the queued events with process stopped, and the dead-letter list kept the newest 100 (dead_letter_size). Real buses (a Redis subscriber, a Kafka consumer) usually await network reads between messages, which gives the listener its turn. Batch publishers in your own code are the common cause of drops: yield between events, or publish to a durable queue the agent reads with a custom trigger.
[10]
The complete example
chain.py from step 2 is the complete program: two tool servers, two agents, a bus, a broker and a shared hook. The folder runs/multi-agent-event-bus/ holds it with the other steps:
event-bus/
├── bus.py # EventBus and Broker with wildcards (step 1)
├── bus_basics.py # what the triggers receive (step 1)
├── chain.py # monitoring -> triage -> broker -> on-call (step 2, needs OPENAI_API_KEY)
├── lifecycle_chain.py # process.failed -> doctor agent (step 4)
├── burst.py # full trigger buffers (step 5)
└── fake_llm.py # scripted model server for steps 4 and 5pip install "promptise>=1.5.0"
export OPENAI_API_KEY=sk-...
python chain.py[11]
Running it in production
Replace the in-memory bus
The in-memory bus only connects agents in one Python process. To connect agents in several programs or containers, wrap a real broker in the same interface. With Redis Pub/Sub, subscribe registers a pattern with PSUBSCRIBE and a background task calls the handlers for each message; publish and emit call PUBLISH. With NATS or Kafka the shape is the same. Keep the handler fast: it should only put the event in the trigger's buffer, which is what the runtime's handler does.
Pub/Sub is at-most-once. An agent that is stopped, restarting or not yet subscribed misses what is published meanwhile, because the trigger unsubscribes at stop(). When an event must not be lost, publish it to a durable queue (Redis Streams, SQS, a job table) and read it with a custom trigger that acknowledges after the run.
Stop loops between agents
Nothing stops agent A from publishing what wakes agent B, which publishes what wakes agent A. Guard chains in the publish tool: carry a hops counter or a correlation ID in every message, refuse to publish beyond a few hops, and never let an agent subscribe to a topic it publishes to. Budgets per process (max_runs_per_day, cost limits) are the backstop; see AI agent cost control.
Treat other agents' output as untrusted
A message from another agent may carry text that came from a customer, a web page or an attacker. The runtime puts every trigger payload in a delimited untrusted-data block, and TriggerDeliveryConfig(scan_payloads=True) scans it for prompt injection. Keep side-effecting tools on the receiving agent behind human approval, and let the publish tool restrict which topics each agent may use.
See the whole chain
Add a correlation ID to the first event and copy it into everything published downstream. A runtime-wide task_completed hook (as in chain.py) and the process.* events give you per-agent timing; AI agent lifecycle hooks and AI agent notifications cover both.
[12]
LangGraph handoffs vs CrewAI Flows vs a Promptise event bus
LangGraph, CrewAI and Promptise all let several agents cooperate, at different levels.
LangGraph handoffs | CrewAI Flows | Promptise runtime with an event bus | |
|---|---|---|---|
How control passes | A handoff tool returns Command(goto="agent", update=..., graph=Command.PARENT) | @listen(method) runs when another method returns; @router branches | Agents subscribe to event types or topics; producers publish |
When the next agent runs | In the same graph run | In the same kickoff() | Whenever its event arrives, in its own process |
Shared state | Graph state (messages and your keys) | self.state of the flow | None; only what the event carries |
Coupling | The graph names every agent | The flow class names every method | Only topic and event names |
Starting from outside events | LangSmith Deployment cron jobs and API runs | CrewAI AMP triggers (Gmail, Slack, Stripe...) | Built-in webhook, cron, file, event, message and custom triggers |
Event bus | None for agents | crewai_event_bus for observing execution | Yours: any object with subscribe / unsubscribe |
LangGraph handoffs and CrewAI Flows are the better choice when one request flows through several agents and someone waits for the result: state is shared, the order is explicit, and the graph or flow is easy to test as a unit. CrewAI's crewai_event_bus is for observing execution (logging, tracing), not for waking crews. A Promptise event bus fits the other case: independent, long-running agents that react to the same stream of events at their own pace, can be added or removed without editing the others, and fail separately. The cost is that you design the topics, the delivery guarantees and loop protection yourself. The two approaches combine well: a graph can be the agent inside one subscriber process.
[13]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
| The runtime has no bus | AgentRuntime(event_bus=bus); for message triggers, broker=broker |
An agent never wakes up | Producer and runtime use different bus instances | Pass the same object to both |
event_source matches nothing | Your bus doesn't set source on the delivered object | Set it in emit; derive it from the data if needed |
Events from a burst are missing | The trigger buffer (100) filled while the producer didn't yield | Yield between events; use a durable queue for big batches |
The agent can't tell which topic fired | The payload's topic is the subscribed pattern | Put the concrete topic in the message content |
Two agents wake each other forever | A publish/subscribe cycle | Hop counter or correlation ID in the publish tool; never subscribe to your own topics |
A lifecycle subscriber reacts to its own failures | It subscribed to process.failed for all processes |
|
Events published during a restart are lost | Pub/Sub has no buffer while unsubscribed | Durable queue plus a custom trigger |
[14]
Limitations
No bus included. You write or wrap one; the in-memory version only works within one program.
At-most-once by default. Dropped events (full buffer, agent stopped) leave only a log line.
Silent drops. event and message triggers have no dropped_events counter, and drops don't reach the dead letters.
No built-in request/reply. An agent can't publish and wait for an answer; use a direct call for that.
Wildcards depend on your broker. The runtime passes the pattern through and reports the pattern, not the topic, in the payload.
[15]
Frequently asked questions
What is a multi-agent event bus?
It is a publish/subscribe channel that connects agents. Agents and other systems publish events or topic messages, and every agent subscribed to that type or topic wakes up and runs. Publishers don't know their subscribers, so you can add, remove or restart agents without changing the others.
How do AI agents communicate through pub/sub?
An agent publishes by calling a tool that sends a message to the bus. Another agent, running as its own long-lived process, subscribes to the topic and receives the message as the input of a new run. In the Promptise runtime, subscriptions are event or message triggers on a bus and broker you pass to AgentRuntime.
Do I need Kafka or Redis for a multi-agent system?
Not to start. An in-memory bus is enough when all agents run in one program. Use Redis, NATS or Kafka when agents run in several processes or machines, and a durable queue or stream when events must survive restarts. The trigger interface stays the same; only the bus implementation changes.
How do I stop agents from triggering each other in a loop?
Put a hop counter or correlation ID in every message, have the publish tool refuse messages beyond a few hops, and never subscribe an agent to topics it publishes. For lifecycle events, filter out the subscriber's own name with filter_expression. Per-agent run and cost budgets cap the damage if a loop slips through.
What happens if events arrive faster than an agent can handle them?
They wait. Each trigger buffers 100 events and the process queue holds 1,000, and workers take them in order (concurrency controls how many run at once). If a producer emits more than 100 events without yielding, the trigger drops the rest with a log warning. For large or bursty volumes, put a durable queue in between.
Should I use LangGraph handoffs or an event bus?
Use LangGraph handoffs (or CrewAI Flows) when one request moves through several agents and someone waits for the result, with shared state and a fixed order. Use an event bus, such as the Promptise runtime's event and message triggers, when independent agents react to a stream of events on their own schedule and must be deployed, scaled and fail separately.
[16]
Next steps
Design the agents first: how to build a multi-agent system in Python.
Run and supervise many agents in one runtime: run multiple AI agents.
Filter what wakes each agent: AI agent event filters.
Read from durable queues: custom AI agent triggers.
Handle failed runs: AI agent retries and a dead letter queue.
Reference: event and webhook triggers and the runtime manager in the Promptise docs.