Wire agents together with a message bus instead of function calls

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.

Your agents talk to each other through nested function calls. One stage's failure cascades through the stack and you lose state.

Opening thesis

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.

Before

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.

Architecture

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.

Message bus agent pipelineThree Anthropic-powered agents coordinated through Inngest events Nodes: Trigger (HTTP request) (kicks off the pipeline with a topic); Inngest Event Bus (routes events between agents); Research Agent (Inngest fn) (calls Anthropic to gather context); Draft Agent (Inngest fn) (calls Anthropic to write copy from context); Review Agent (Inngest fn) (calls Anthropic to score and critique the draft); Result Store (step.run output) (persisted state per step).Trigger (HTTP request)kicks off the pipeline with a topicInngest Event Busroutes events between agentsResearch Agent (Inngest fn)calls Anthropic to gather contextDraft Agent (Inngest fn)calls Anthropic to write copy from contextReview Agent (Inngest fn)calls Anthropic to score and critique the draftResult Store (step.run output)persisted state per step
  • Trigger sends "pipeline.started" to Event Bus
  • Event Bus triggers Research Agent on "pipeline.started"
  • Research Agent sends "research.completed" with payload to Event Bus
  • Event Bus triggers Draft Agent on "research.completed"
  • Draft Agent sends "draft.completed" with payload to Event Bus
  • Event Bus triggers Review Agent on "draft.completed"
  • Review Agent sends "review.completed" with final output to Event Bus

Step-by-step implementation

1. Set up the project and install dependencies

Create a new Node.js project. Install inngest for the event bus and the Anthropic SDK for agent reasoning.

mkdir agent-bus && cd agent-bus
npm init -y
npm install inngest@^3.0.0 @anthropic-ai/sdk@^0.30.0 express@^4.18.0

2. Set environment variables

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.

export ANTHROPIC_API_KEY="sk-ant-..."
export INNGEST_EVENT_KEY="your-event-key"
export INNGEST_SIGNING_KEY="signkey-..."

3. Create the Anthropic client helper

Wrap the Anthropic call in a single function so every agent uses the same model and parameters. This file is claude.js.

// claude.js
const Anthropic = require("@anthropic-ai/sdk");

const client = new Anthropic();

async function ask(systemPrompt, userMessage) {
  const response = await client.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: systemPrompt,
    messages: [{ role: "user", content: userMessage }],
  });
  return response.content[0].text;
}

module.exports = { ask };

4. Define the Research agent

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.

// agents/research.js
const { inngest } = require("../inngestClient");
const { ask } = require("../claude");

const researchAgent = inngest.createFunction(
  { id: "research-agent", retries: 3 },
  { event: "pipeline.started" },
  async ({ event, step }) => {
    const notes = await step.run("gather-research", async () => {
      return ask(
        "You are a research assistant. Return 5 bullet points of key facts.",
        `Topic: ${event.data.topic}`
      );
    });

    await step.sendEvent("emit-research", {
      name: "research.completed",
      data: { topic: event.data.topic, notes },
    });

    return { notes };
  }
);

module.exports = { researchAgent };

5. Define the Draft agent

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.

// agents/draft.js
const { inngest } = require("../inngestClient");
const { ask } = require("../claude");

const draftAgent = inngest.createFunction(
  { id: "draft-agent", retries: 3 },
  { event: "research.completed" },
  async ({ event, step }) => {
    const draft = await step.run("write-draft", async () => {
      return ask(
        "You are a technical writer. Write a 200-word article using only the provided notes.",
        `Topic: ${event.data.topic}\n\nNotes:\n${event.data.notes}`
      );
    });

    await step.sendEvent("emit-draft", {
      name: "draft.completed",
      data: { topic: event.data.topic, draft },
    });

    return { draft };
  }
);

module.exports = { draftAgent };

6. Define the Review agent

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.

// agents/review.js
const { inngest } = require("../inngestClient");
const { ask } = require("../claude");

const reviewAgent = inngest.createFunction(
  { id: "review-agent", retries: 3 },
  { event: "draft.completed" },
  async ({ event, step }) => {
    const review = await step.run("review-draft", async () => {
      return ask(
        "You are an editor. Score this draft 1-10. List three specific improvements.",
        `Draft:\n${event.data.draft}`
      );
    });

    await step.sendEvent("emit-review", {
      name: "review.completed",
      data: { topic: event.data.topic, review },
    });

    return { review };
  }
);

module.exports = { reviewAgent };

7. Create the Inngest client

This shared client is imported by every agent file. One client, one bus.

// inngestClient.js
const { Inngest } = require("inngest");

const inngest = new Inngest({ id: "agent-bus" });

module.exports = { inngest };

8. Wire up the Express server and serve functions

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.

// server.js
const express = require("express");
const { serve } = require("inngest/express");
const { inngest } = require("./inngestClient");
const { researchAgent } = require("./agents/research");
const { draftAgent } = require("./agents/draft");
const { reviewAgent } = require("./agents/review");

const app = express();
app.use(express.json());

app.use("/api/inngest", serve({ client: inngest, functions: [researchAgent, draftAgent, reviewAgent] }));

app.post("/trigger", async (req, res) => {
  await inngest.send({ name: "pipeline.started", data: { topic: req.body.topic } });
  res.json({ status: "pipeline triggered" });
});

app.listen(3000, () => console.log("Server running on port 3000"));

9. Start the Inngest Dev Server and test locally

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.

# Terminal 1: start Inngest Dev Server
npx inngest-cli@latest dev

# Terminal 2: start your app
node server.js

# Terminal 3: trigger the pipeline
curl -X POST http://localhost:3000/trigger \
  -H "Content-Type: application/json" \
  -d '{"topic": "how message buses improve agent reliability"}'

10. Observe the pipeline in the Inngest dashboard

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.

# Open the local Inngest dashboard
open http://localhost:8288

Breakage

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.

Pipeline breakage without retriesDraft agent fails on a transient API error and the pipeline halts Nodes: Research Agent (completes successfully); Inngest Event Bus (holds "research.completed" event); Draft Agent (no retries, no step tracking) (fails on 529 error); Review Agent (never triggered).Research Agentcompletes successfullyInngest Event Busholds "research.completed" eventDraft Agent (no retries, no step tracking)fails on 529 errorReview Agentnever triggered
  • Research Agent sends "research.completed" to Event Bus
  • Event Bus triggers Draft Agent
  • Draft Agent calls Anthropic API directly (no step.run)
  • Anthropic returns 529 overloaded
  • Draft Agent throws, no retry, no state saved
  • No "draft.completed" event emitted
  • Review Agent sits idle

The fix

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:

// In agents/draft.js, the function config and step wrapper are the fix.
// retries: 3 gives you automatic exponential backoff.
// step.run persists the output so retries skip completed work.

const draftAgent = inngest.createFunction(
  { id: "draft-agent", retries: 3 },
  { event: "research.completed" },
  async ({ event, step }) => {
    const draft = await step.run("write-draft", async () => {
      return ask(
        "You are a technical writer. Write a 200-word article using only the provided notes.",
        `Topic: ${event.data.topic}\n\nNotes:\n${event.data.notes}`
      );
    });

    await step.sendEvent("emit-draft", {
      name: "draft.completed",
      data: { topic: event.data.topic, draft },
    });

    return { draft };
  }
);

Fixed state

Pipeline with retries and step persistenceDraft agent retries on transient failure, resumes from last completed step Nodes: Research Agent (completes, output persisted); Inngest Event Bus (routes events, stores payloads); Draft Agent (retries: 3, step.run) (retries on 529, skips completed steps); Review Agent (triggered after draft.completed arrives); Step State Store (Inngest persists each step.run output).Research Agentcompletes, output persistedInngest Event Busroutes events, stores payloadsDraft Agent (retries: 3, step.run)retries on 529, skips completed stepsReview Agenttriggered after draft.completed arrivesStep State StoreInngest persists each step.run output
  • Research Agent sends "research.completed" to Event Bus
  • Event Bus triggers Draft Agent
  • Draft Agent calls Anthropic via step.run("write-draft")
  • Anthropic returns 529 on first attempt
  • Inngest retries Draft Agent with exponential backoff
  • On retry, step.run("write-draft") re-executes (no prior success to skip)
  • Anthropic returns 200 on second attempt
  • step.run output persisted to Step State Store
  • Draft Agent sends "draft.completed" to Event Bus
  • Event Bus triggers Review Agent
  • Review Agent completes, sends "review.completed"

After

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.

Takeaway

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.

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.

This tutorial is part of the Builder Weekly Tutorials corpus, licensed under CC BY 4.0. Fork it, reuse it, adapt it. Attribution required: link back to thebuilderweekly.com/tutorials or the source repository. Spot something wrong, or want to contribute a new tutorial? Open a PR at github.com/thebuilderweekly/ai-building-tutorials.