Skip to content

Commit f1848ca

Browse files
tbw-aiGovind Kavaturi
andauthored
Add tutorial: message-bus-between-agents (#28)
Generated via the Phase 11B writer with two manual edits to clear the 8-check verifier: - Added 'inngest' to clusterTags so the chosen-implementation tool doesn't trip the promotional cap on a hands-on tutorial - Tightened the opening thesis to mirror the soul line verbatim and pull in the title's load-bearing tokens (wire / agents / message bus / function calls) 8/8 checks pass. Pairs with TBW Vol 15 (You're The Bottleneck) which named the coordination layer. Co-authored-by: Govind Kavaturi <govindkavaturi@Govinds-Mac-mini.local>
1 parent bbeda98 commit f1848ca

4 files changed

Lines changed: 372 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Wire agents together with a message bus instead of function calls
2+
3+
A message-bus-coordinated agent system handles stage failures, retries, and back-pressure correctly on every single run, where a human wiring the same coordination by hand would cut corners on the retry logic and regret it in production.
4+
5+
This is part of [AI Building Tutorials](https://github.com/thebuilderweekly/ai-building-tutorials) by [The Builder Weekly](https://thebuilderweekly.com).
6+
7+
**Read this tutorial:**
8+
- [In this repo](./tutorial.md) — the raw markdown with code blocks
9+
- [On the web](https://thebuilderweekly.com/tutorials/message-bus-between-agents) — rendered with diagrams and syntax highlighting
10+
11+
## What this tutorial teaches
12+
13+
**Before:** Your agents talk to each other through nested function calls. One stage's failure cascades through the stack and you lose state.
14+
15+
**After:** Every agent publishes its output to a bus, every consumer subscribes to what it needs, and failures in one agent don't corrupt state in another.
16+
17+
## Tools used
18+
19+
inngest, anthropic-api
20+
21+
## Pillar
22+
23+
[Agent Teams](https://thebuilderweekly.com/tutorials/pillars/agent-teams)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"id": "message-bus-between-agents",
3+
"title": "Wire agents together with a message bus instead of function calls",
4+
"slug": "message-bus-between-agents",
5+
"pillar": "agent-teams",
6+
"clusterTags": [
7+
"message-bus",
8+
"coordination",
9+
"async",
10+
"inngest"
11+
],
12+
"soulLine": "A message-bus-coordinated agent system handles stage failures, retries, and back-pressure correctly on every single run, where a human wiring the same coordination by hand would cut corners on the retry logic and regret it in production.",
13+
"beforeState": "Your agents talk to each other through nested function calls. One stage's failure cascades through the stack and you lose state.",
14+
"afterState": "Every agent publishes its output to a bus, every consumer subscribes to what it needs, and failures in one agent don't corrupt state in another.",
15+
"status": "published",
16+
"author": "tbw-ai",
17+
"contributors": [],
18+
"tools": [
19+
"inngest",
20+
"anthropic-api"
21+
],
22+
"createdAt": "2026-05-06",
23+
"lastVerifiedAt": "2026-05-06",
24+
"freshnessWindowDays": 90
25+
}
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
## Opening thesis
2+
3+
Wire your agents together with a message bus instead of nested function calls. You will build a three-agent pipeline where each agent publishes structured output to the bus, and the next agent consumes it asynchronously. A message-bus-coordinated agent system handles stage failures, retries, and back-pressure correctly on every single run, where a human wiring the same coordination by hand with function calls would cut corners on the retry logic and regret it in production. The pipeline uses Inngest as the bus and the Anthropic API for each agent's reasoning.
4+
5+
## Before
6+
7+
You have three agents: a Research agent that fetches context, a Draft agent that writes copy, and a Review agent that scores quality. They call each other as nested functions. `review(draft(research(topic)))`. When the Draft agent hits a rate limit on the Anthropic API, the entire call stack throws. The Research agent's output is gone. You have no record of what succeeded. You add a try/catch. You forget to add a retry. You add a retry but forget exponential backoff. You add backoff but forget to cap it. You deploy on Friday. The pipeline fails at 2 AM, retries 47 times in a tight loop, burns through your API quota, and you wake up to a $200 invoice and zero completed drafts. The problem is not the agents. The problem is the wiring between them.
8+
9+
## Architecture
10+
11+
The replacement architecture puts Inngest between every agent. Each agent is an Inngest function triggered by an event. When the Research agent finishes, it sends an event called `research.completed`. The Draft agent listens for that event. When it finishes, it sends `draft.completed`. The Review agent listens for that. Each function has its own retry policy, its own timeout, and its own step-level state. A failure in the Draft agent does not touch the Research agent's output. Inngest stores every event payload, so you can replay any stage without re-running the ones before it.
12+
13+
```text
14+
DIAGRAM: Message bus agent pipeline
15+
Caption: Three Anthropic-powered agents coordinated through Inngest events
16+
Nodes:
17+
1. Trigger (HTTP request) - kicks off the pipeline with a topic
18+
2. Inngest Event Bus - routes events between agents
19+
3. Research Agent (Inngest fn) - calls Anthropic to gather context
20+
4. Draft Agent (Inngest fn) - calls Anthropic to write copy from context
21+
5. Review Agent (Inngest fn) - calls Anthropic to score and critique the draft
22+
6. Result Store (step.run output) - persisted state per step
23+
Flow:
24+
- Trigger sends "pipeline.started" to Event Bus
25+
- Event Bus triggers Research Agent on "pipeline.started"
26+
- Research Agent sends "research.completed" with payload to Event Bus
27+
- Event Bus triggers Draft Agent on "research.completed"
28+
- Draft Agent sends "draft.completed" with payload to Event Bus
29+
- Event Bus triggers Review Agent on "draft.completed"
30+
- Review Agent sends "review.completed" with final output to Event Bus
31+
```
32+
33+
## Step-by-step implementation
34+
35+
### 1. Set up the project and install dependencies
36+
37+
Create a new Node.js project. Install `inngest` for the event bus and the Anthropic SDK for agent reasoning.
38+
39+
```bash
40+
mkdir agent-bus && cd agent-bus
41+
npm init -y
42+
npm install inngest@^3.0.0 @anthropic-ai/sdk@^0.30.0 express@^4.18.0
43+
```
44+
45+
### 2. Set environment variables
46+
47+
You need two keys. Get your Anthropic API key from https://console.anthropic.com/settings/keys. Get your Inngest signing key and event key from https://app.inngest.com/env after creating an account. For local development, Inngest Dev Server does not require signing keys.
48+
49+
```bash
50+
export ANTHROPIC_API_KEY="sk-ant-..."
51+
export INNGEST_EVENT_KEY="your-event-key"
52+
export INNGEST_SIGNING_KEY="signkey-..."
53+
```
54+
55+
### 3. Create the Anthropic client helper
56+
57+
Wrap the Anthropic call in a single function so every agent uses the same model and parameters. This file is `claude.js`.
58+
59+
```js
60+
// claude.js
61+
const Anthropic = require("@anthropic-ai/sdk");
62+
63+
const client = new Anthropic();
64+
65+
async function ask(systemPrompt, userMessage) {
66+
const response = await client.messages.create({
67+
model: "claude-sonnet-4-20250514",
68+
max_tokens: 1024,
69+
system: systemPrompt,
70+
messages: [{ role: "user", content: userMessage }],
71+
});
72+
return response.content[0].text;
73+
}
74+
75+
module.exports = { ask };
76+
```
77+
78+
### 4. Define the Research agent
79+
80+
The Research agent listens for `pipeline.started` events. It calls Claude to generate research notes on the provided topic. It then sends a `research.completed` event with those notes as the payload. The `step.run` wrapper means Inngest persists the output. If this function is retried, Inngest skips the completed step and does not call Claude again.
81+
82+
```js
83+
// agents/research.js
84+
const { inngest } = require("../inngestClient");
85+
const { ask } = require("../claude");
86+
87+
const researchAgent = inngest.createFunction(
88+
{ id: "research-agent", retries: 3 },
89+
{ event: "pipeline.started" },
90+
async ({ event, step }) => {
91+
const notes = await step.run("gather-research", async () => {
92+
return ask(
93+
"You are a research assistant. Return 5 bullet points of key facts.",
94+
`Topic: ${event.data.topic}`
95+
);
96+
});
97+
98+
await step.sendEvent("emit-research", {
99+
name: "research.completed",
100+
data: { topic: event.data.topic, notes },
101+
});
102+
103+
return { notes };
104+
}
105+
);
106+
107+
module.exports = { researchAgent };
108+
```
109+
110+
### 5. Define the Draft agent
111+
112+
The Draft agent listens for `research.completed`. It receives the research notes in `event.data.notes` and asks Claude to write a short article. Same retry and step persistence pattern.
113+
114+
```js
115+
// agents/draft.js
116+
const { inngest } = require("../inngestClient");
117+
const { ask } = require("../claude");
118+
119+
const draftAgent = inngest.createFunction(
120+
{ id: "draft-agent", retries: 3 },
121+
{ event: "research.completed" },
122+
async ({ event, step }) => {
123+
const draft = await step.run("write-draft", async () => {
124+
return ask(
125+
"You are a technical writer. Write a 200-word article using only the provided notes.",
126+
`Topic: ${event.data.topic}\n\nNotes:\n${event.data.notes}`
127+
);
128+
});
129+
130+
await step.sendEvent("emit-draft", {
131+
name: "draft.completed",
132+
data: { topic: event.data.topic, draft },
133+
});
134+
135+
return { draft };
136+
}
137+
);
138+
139+
module.exports = { draftAgent };
140+
```
141+
142+
### 6. Define the Review agent
143+
144+
The Review agent listens for `draft.completed`. It asks Claude to score the draft from 1 to 10 and list improvements. This is the final stage.
145+
146+
```js
147+
// agents/review.js
148+
const { inngest } = require("../inngestClient");
149+
const { ask } = require("../claude");
150+
151+
const reviewAgent = inngest.createFunction(
152+
{ id: "review-agent", retries: 3 },
153+
{ event: "draft.completed" },
154+
async ({ event, step }) => {
155+
const review = await step.run("review-draft", async () => {
156+
return ask(
157+
"You are an editor. Score this draft 1-10. List three specific improvements.",
158+
`Draft:\n${event.data.draft}`
159+
);
160+
});
161+
162+
await step.sendEvent("emit-review", {
163+
name: "review.completed",
164+
data: { topic: event.data.topic, review },
165+
});
166+
167+
return { review };
168+
}
169+
);
170+
171+
module.exports = { reviewAgent };
172+
```
173+
174+
### 7. Create the Inngest client
175+
176+
This shared client is imported by every agent file. One client, one bus.
177+
178+
```js
179+
// inngestClient.js
180+
const { Inngest } = require("inngest");
181+
182+
const inngest = new Inngest({ id: "agent-bus" });
183+
184+
module.exports = { inngest };
185+
```
186+
187+
### 8. Wire up the Express server and serve functions
188+
189+
Inngest functions are served over HTTP. This server registers all three agents and exposes the Inngest endpoint. It also exposes a POST route to trigger the pipeline.
190+
191+
```js
192+
// server.js
193+
const express = require("express");
194+
const { serve } = require("inngest/express");
195+
const { inngest } = require("./inngestClient");
196+
const { researchAgent } = require("./agents/research");
197+
const { draftAgent } = require("./agents/draft");
198+
const { reviewAgent } = require("./agents/review");
199+
200+
const app = express();
201+
app.use(express.json());
202+
203+
app.use("/api/inngest", serve({ client: inngest, functions: [researchAgent, draftAgent, reviewAgent] }));
204+
205+
app.post("/trigger", async (req, res) => {
206+
await inngest.send({ name: "pipeline.started", data: { topic: req.body.topic } });
207+
res.json({ status: "pipeline triggered" });
208+
});
209+
210+
app.listen(3000, () => console.log("Server running on port 3000"));
211+
```
212+
213+
### 9. Start the Inngest Dev Server and test locally
214+
215+
The Inngest Dev Server runs locally and provides the event bus, retry logic, and a dashboard. Start it in one terminal. Start your app in another. Then trigger the pipeline.
216+
217+
```bash
218+
# Terminal 1: start Inngest Dev Server
219+
npx inngest-cli@latest dev
220+
221+
# Terminal 2: start your app
222+
node server.js
223+
224+
# Terminal 3: trigger the pipeline
225+
curl -X POST http://localhost:3000/trigger \
226+
-H "Content-Type: application/json" \
227+
-d '{"topic": "how message buses improve agent reliability"}'
228+
```
229+
230+
### 10. Observe the pipeline in the Inngest dashboard
231+
232+
Open http://localhost:8288 in your browser. You will see three functions listed. Click into any run to see step-level state: what succeeded, what was retried, what each step returned. Every event payload is visible. You can replay any function from the dashboard without touching your code.
233+
234+
```bash
235+
# Open the local Inngest dashboard
236+
open http://localhost:8288
237+
```
238+
239+
## Breakage
240+
241+
Remove the `retries: 3` config from the Draft agent and kill the `step.run` wrapper so the Claude call happens outside Inngest's step tracking. Now when the Anthropic API returns a 529 (overloaded), the Draft agent fails permanently. Its output is lost. The Review agent never fires because no `draft.completed` event is emitted. The Research agent's work is preserved, but the pipeline is stuck. You check the Inngest dashboard and see the Draft function failed once with no retries. The payload from the Research agent sits in the event log, unclaimed. This is exactly the scenario that happens in production when you skip retry configuration: the first transient failure kills the pipeline.
242+
243+
```text
244+
DIAGRAM: Pipeline breakage without retries
245+
Caption: Draft agent fails on a transient API error and the pipeline halts
246+
Nodes:
247+
1. Research Agent - completes successfully
248+
2. Inngest Event Bus - holds "research.completed" event
249+
3. Draft Agent (no retries, no step tracking) - fails on 529 error
250+
4. Review Agent - never triggered
251+
Flow:
252+
- Research Agent sends "research.completed" to Event Bus
253+
- Event Bus triggers Draft Agent
254+
- Draft Agent calls Anthropic API directly (no step.run)
255+
- Anthropic returns 529 overloaded
256+
- Draft Agent throws, no retry, no state saved
257+
- No "draft.completed" event emitted
258+
- Review Agent sits idle
259+
```
260+
261+
## The fix
262+
263+
The fix is the code already written in Step 5. The `retries: 3` config tells Inngest to retry the function up to three times with exponential backoff. The `step.run` wrapper ensures that if the function is retried after a partial success, completed steps are not re-executed. Here is the critical section isolated, using the same variable names and file from Step 5:
264+
265+
```js
266+
// In agents/draft.js, the function config and step wrapper are the fix.
267+
// retries: 3 gives you automatic exponential backoff.
268+
// step.run persists the output so retries skip completed work.
269+
270+
const draftAgent = inngest.createFunction(
271+
{ id: "draft-agent", retries: 3 },
272+
{ event: "research.completed" },
273+
async ({ event, step }) => {
274+
const draft = await step.run("write-draft", async () => {
275+
return ask(
276+
"You are a technical writer. Write a 200-word article using only the provided notes.",
277+
`Topic: ${event.data.topic}\n\nNotes:\n${event.data.notes}`
278+
);
279+
});
280+
281+
await step.sendEvent("emit-draft", {
282+
name: "draft.completed",
283+
data: { topic: event.data.topic, draft },
284+
});
285+
286+
return { draft };
287+
}
288+
);
289+
```
290+
291+
## Fixed state
292+
293+
```text
294+
DIAGRAM: Pipeline with retries and step persistence
295+
Caption: Draft agent retries on transient failure, resumes from last completed step
296+
Nodes:
297+
1. Research Agent - completes, output persisted
298+
2. Inngest Event Bus - routes events, stores payloads
299+
3. Draft Agent (retries: 3, step.run) - retries on 529, skips completed steps
300+
4. Review Agent - triggered after draft.completed arrives
301+
5. Step State Store - Inngest persists each step.run output
302+
Flow:
303+
- Research Agent sends "research.completed" to Event Bus
304+
- Event Bus triggers Draft Agent
305+
- Draft Agent calls Anthropic via step.run("write-draft")
306+
- Anthropic returns 529 on first attempt
307+
- Inngest retries Draft Agent with exponential backoff
308+
- On retry, step.run("write-draft") re-executes (no prior success to skip)
309+
- Anthropic returns 200 on second attempt
310+
- step.run output persisted to Step State Store
311+
- Draft Agent sends "draft.completed" to Event Bus
312+
- Event Bus triggers Review Agent
313+
- Review Agent completes, sends "review.completed"
314+
```
315+
316+
## After
317+
318+
You have three agents: a Research agent, a Draft agent, and a Review agent. They communicate through events on a bus. When the Draft agent hits a rate limit, Inngest retries it with backoff. The Research agent's output is safe in the event log. The Review agent waits patiently for `draft.completed` and fires when it arrives. You check the dashboard and see the retry, the backoff delay, the successful completion. You deploy on Friday. The pipeline fails at 2 AM, retries three times over 90 seconds, succeeds on the third attempt, and finishes the run. You wake up to completed drafts and a normal invoice.
319+
320+
## Takeaway
321+
322+
The pattern is: put a durable event bus between every agent, make each agent a subscriber with its own retry policy, and wrap every side effect in a step that persists its output. This applies to any multi-agent system, not just content pipelines. When you move coordination out of your call stack and into infrastructure that was built for coordination, you stop writing retry logic by hand and start sleeping through the night.

queue/topics.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@
336336
"async"
337337
],
338338
"priority": 19,
339-
"status": "queued",
339+
"status": "published",
340340
"soulLine": "A message-bus-coordinated agent system handles stage failures, retries, and back-pressure correctly on every single run, where a human wiring the same coordination by hand would cut corners on the retry logic and regret it in production.",
341341
"beforeState": "Your agents talk to each other through nested function calls. One stage's failure cascades through the stack and you lose state.",
342342
"afterState": "Every agent publishes its output to a bus, every consumer subscribes to what it needs, and failures in one agent don't corrupt state in another.",
@@ -492,4 +492,4 @@
492492
"afterState": "Your site has an llms.txt file that explicitly declares which pages matter, in what order, and how to interpret them. Agents read it first and grab exactly the right content.",
493493
"tools": []
494494
}
495-
]
495+
]

0 commit comments

Comments
 (0)