Three agents talking to each other over REST is fine. It's a few RestTemplate calls, some retry logic, maybe a circuit breaker if you're careful. Six agents is a mess of point-to-point HTTP calls, each one a new failure mode. By the time we had a Research Agent, a Planner Agent, a Code Agent, a Tool-Execution Agent, a Critique Agent, and a Human-Approval gate, we had something worse than spaghetti — we had spaghetti that occasionally lost messages and gave us no way to answer "wait, why did the agent decide to do that?" after the fact.
The fix wasn't a smarter orchestration framework. It was ripping out direct agent-to-agent HTTP calls and putting Kafka in the middle of everything. Every agent decision, every tool call, every reasoning step became an event on a topic instead of a function call between services. Here's the full architecture, the Java/Spring Kafka code, and — more importantly — the failure modes REST has that nobody mentions until they've paged you at 2AM.
Why REST Between Agents Falls Apart
A single agent calling a single LLM and returning a result is a request/response problem, and REST is the right tool for that. A system of agents — where Agent A's output becomes Agent B's input, which might trigger Agent C, which might need a human to approve before Agent D runs — is not a request/response problem. It's a workflow with branches, retries, and long-running state. Forcing that through synchronous HTTP calls creates four specific problems:
Tight coupling. If the Planner Agent calls the Code Agent directly over HTTP, the Planner needs to know the Code Agent's address, its contract, and how to handle its downtime. Add a seventh agent and you've added a new set of point-to-point contracts to every agent that talks to it.
No audit trail. When an agent chain produces a wrong answer, you need to know why — which agent decided what, in what order, with what tool results. HTTP calls don't leave a log you can replay. You get scattered application logs across six services, if you're lucky enough to have correlation IDs wired through all of them.
Cascading timeouts. Agent A calls Agent B calls Agent C. If C is slow, B's HTTP client times out, which fails A's call, which fails the whole chain — even though C would have finished fine given another five seconds. Compounding timeouts across a chain of synchronous calls is one of the most common reasons "the agent just hangs" reports turn into full outages.
No natural pause point. The moment you need a human to approve a risky action (delete a record, send an email, spend money) partway through an agent's reasoning, synchronous HTTP has nowhere good to put that pause. You either block a thread for however long the human takes, or you bolt on a separate polling mechanism that wasn't part of the original design.
| Problem |
Direct HTTP calls |
Kafka-mediated |
| Coupling |
Every agent knows every other agent's address |
Agents only know topic names |
| Audit trail |
Scattered logs, hard to reconstruct |
Full event log, replayable by design |
| Partial failure |
Cascading timeouts up the call chain |
Failed step retried independently; rest of chain unaffected |
| Human-in-the-loop |
Bolted on with polling or long-lived connections |
A topic like any other — no special-casing |
| Scaling one agent type |
Redeploy + reconfigure callers |
Add consumers to its consumer group |
| Replay / debugging |
Not possible after the fact |
Re-consume the topic from any offset |
The Core Idea: Agents Communicate Through Events, Not Calls
Instead of Agent A calling Agent B's endpoint, Agent A publishes an event describing what it wants done. Whichever agent (or pool of agents) is responsible for that kind of work consumes it, does its job, and publishes a new event describing the outcome. No agent ever calls another agent directly — they only know about topics.
flowchart TD
U([User request]) --> ORC[Orchestrator Agent]
ORC -->|publish| T1[(agent.tasks.plan)]
T1 --> PLAN[Planner Agent]
PLAN -->|publish| T2[(agent.tasks.research)]
PLAN -->|publish| T3[(agent.tasks.code)]
T2 --> RES[Research Agent]
T3 --> CODE[Code Agent]
RES -->|tool call needed| TC[(agent.tool-calls.requested)]
CODE -->|tool call needed| TC
TC --> EXE[Tool Execution Service]
EXE -->|result| TR[(agent.tool-calls.completed)]
TR --> RES
TR --> CODE
RES -->|publish| DONE[(agent.results.completed)]
CODE -->|publish| APPR[(agent.approvals.pending)]
APPR --> HUMAN{{Human reviewer}}
HUMAN -->|approve/reject| DEC[(agent.approvals.decided)]
DEC --> CODE
CODE -->|publish| DONE
DONE --> ORC
ORC --> RESP([Response to user])
style T1 fill:#1e3a5f,color:#7dd3fc
style T2 fill:#1e3a5f,color:#7dd3fc
style T3 fill:#1e3a5f,color:#7dd3fc
style TC fill:#1e3a5f,color:#7dd3fc
style TR fill:#1e3a5f,color:#7dd3fc
style DONE fill:#065f46,color:#6ee7b7
style APPR fill:#7c2d12,color:#fdba74
style DEC fill:#7c2d12,color:#fdba74
Every arrow into a cylinder is a Kafka topic. Every box is a consumer group that can scale independently. Nobody calls anybody — they publish, and whoever's listening picks up the work. This is the same decoupling you'd apply to any microservice architecture; agents just happen to be services whose "business logic" is an LLM reasoning loop instead of a database transaction.
[!NOTE]
This isn't a replacement for the LLM call itself — an agent still calls the model provider synchronously inside its own consumer loop. Kafka replaces the connective tissue between agents, not the model call within one.
Topic Design: The Part That Actually Matters
Get this wrong and you'll be renaming topics in production for months. A few rules we settled on after getting it wrong once:
One topic per task type, not one shared agent.tasks topic. agent.tasks.plan, agent.tasks.research, agent.tasks.code each scale independently, each get their own retention policy, and each give you a clean consumer-lag metric — if the Code Agent is falling behind, you see it immediately on agent.tasks.code, not buried in a shared topic's aggregate lag.
Key every message by conversationId, not randomly. Kafka guarantees ordering within a partition, and a partition is chosen by key. If two events for the same conversation land on different partitions, they can be processed out of order — an agent might see a tool result before it sees the task that requested it. Keying by conversationId keeps everything for one user session in order, on one partition, consumed by one thread.
Separate the reasoning trace from the task queue. agent.tasks.* carries "do this next." A parallel agent.thoughts.* topic (or a single agent.thoughts topic keyed the same way) carries every intermediate reasoning step the agent produced — this is what turns into your audit trail and replay capability. Don't conflate "what to do" with "what happened."
Give tool calls their own request/response topic pair, exactly like the task topics. agent.tool-calls.requested and agent.tool-calls.completed decouple tool execution from the agent that requested it — the tool executor doesn't know or care which agent asked, it just processes requests and publishes results.
@Configuration
public class AgentTopicConfig {
public static final String TASKS_PLAN = "agent.tasks.plan";
public static final String TASKS_RESEARCH = "agent.tasks.research";
public static final String TASKS_CODE = "agent.tasks.code";
public static final String THOUGHTS = "agent.thoughts";
public static final String TOOL_REQUESTED = "agent.tool-calls.requested";
public static final String TOOL_COMPLETED = "agent.tool-calls.completed";
public static final String APPROVALS_PENDING = "agent.approvals.pending";
public static final String APPROVALS_DECIDED = "agent.approvals.decided";
public static final String RESULTS_COMPLETED = "agent.results.completed";
public static final String RESULTS_DLQ = "agent.results.dlq";
@Bean
public NewTopic tasksResearch() {
return TopicBuilder.name(TASKS_RESEARCH)
.partitions(12)
.replicas(3)
.config(TopicConfig.RETENTION_MS_CONFIG, "1209600000")
.build();
}
@Bean
public NewTopic thoughts() {
return TopicBuilder.name(THOUGHTS)
.partitions(12)
.replicas(3)
.config(TopicConfig.RETENTION_MS_CONFIG, "2592000000")
.build();
}
@Bean
public NewTopic toolCallsRequested() {
return TopicBuilder.name(TOOL_REQUESTED)
.partitions(24)
.replicas(3)
.build();
}
}
Each Agent Is a Consumer Loop Running ReAct
Strip away the Kafka plumbing and every agent is doing the same thing: consume a task, reason about it (optionally calling tools), and publish a result. The ReAct loop — reason, act, observe, repeat — maps directly onto a state machine, and Kafka events are exactly what drive state transitions.
stateDiagram-v2
[*] --> Idle
Idle --> Reasoning: consume agent.tasks.*
Reasoning --> ToolCallPending: LLM decides to call a tool
Reasoning --> AwaitingApproval: LLM decides on a risky action
Reasoning --> Done: LLM produces final answer
ToolCallPending --> Reasoning: consume agent.tool-calls.completed
AwaitingApproval --> Reasoning: consume agent.approvals.decided (approved)
AwaitingApproval --> Aborted: consume agent.approvals.decided (rejected)
Done --> [*]: publish agent.results.completed
Aborted --> [*]: publish agent.results.completed (aborted)
The important detail: the agent's "state" is just its position in this loop, and every transition is caused by consuming a Kafka message. That means an agent worker can crash mid-reasoning and a replacement instance in the same consumer group picks up exactly where it left off — the last message it hadn't yet acknowledged gets redelivered. You don't need to build your own state-recovery mechanism; consumer offsets already are one.
@Component
public class ResearchAgentWorker {
private final ChatClient chatClient;
private final KafkaTemplate<String, Object> kafka;
private final Set<String> seenTaskIds = ConcurrentHashMap.newKeySet();
private final ConcurrentHashMap<String, AgentTask> pending = new ConcurrentHashMap<>();
public ResearchAgentWorker(ChatClient.Builder chatClientBuilder, KafkaTemplate<String, Object> kafka) {
this.chatClient = chatClientBuilder.build();
this.kafka = kafka;
}
@KafkaListener(topics = AgentTopicConfig.TASKS_RESEARCH, groupId = "research-agent")
public void onTask(AgentTask task, @Header(KafkaHeaders.RECEIVED_KEY) String conversationId) {
if (!seenTaskIds.add(task.taskId())) return;
pending.put(task.taskId(), task);
AgentThought thought = reason(task);
kafka.send(AgentTopicConfig.THOUGHTS, conversationId, thought);
if (thought.needsTool()) {
ToolCallRequested request = new ToolCallRequested(
task.taskId(), conversationId, thought.toolName(), thought.toolArgs());
kafka.send(AgentTopicConfig.TOOL_REQUESTED, conversationId, request);
return;
}
AgentResult result = new AgentResult(task.taskId(), conversationId, thought.finalAnswer());
kafka.send(AgentTopicConfig.RESULTS_COMPLETED, conversationId, result);
pending.remove(task.taskId());
}
@KafkaListener(topics = AgentTopicConfig.TOOL_COMPLETED, groupId = "research-agent")
public void onToolResult(ToolCallCompleted result, @Header(KafkaHeaders.RECEIVED_KEY) String conversationId) {
AgentTask original = pending.get(result.taskId());
if (original == null) return;
seenTaskIds.remove(result.taskId());
onTask(original.withToolResult(result), conversationId);
}
private AgentThought reason(AgentTask task) {
return chatClient.prompt()
.system(task.systemPrompt())
.user(task.renderedContext())
.call()
.entity(AgentThought.class);
}
}
The line worth pausing on is the early return after publishing a tool-call request. The consumer thread does not block waiting for the tool result. It commits the offset for the task message and moves on — Kafka's consumer group can pick up the next task immediately. When the tool result arrives, it's a new message that resumes this conversation from wherever it left off. This is what makes a Kafka-driven agent fundamentally more scalable than one that blocks a thread per in-flight tool call: your thread pool size stops being coupled to how many agent conversations can be in-flight at once.
One Full Task, Start to Finish
Here's what actually crosses the wire (well — the topics) for one user request that needs a tool call and triggers human approval before completing:
sequenceDiagram
participant U as User
participant O as Orchestrator
participant K as Kafka
participant A as Code Agent
participant T as Tool Executor
participant H as Human Reviewer
U->>O: "Refactor the payment module"
O->>K: publish agent.tasks.code
K->>A: deliver task
A->>A: reason (LLM call)
A->>K: publish agent.thoughts (reasoning trace)
A->>K: publish agent.tool-calls.requested (read_file)
K->>T: deliver tool call
T->>T: execute (sandboxed)
T->>K: publish agent.tool-calls.completed
K->>A: deliver tool result
A->>A: reason again — decides this needs approval (writes to prod code)
A->>K: publish agent.approvals.pending
K->>H: notify reviewer (dashboard / Slack)
H->>K: publish agent.approvals.decided (approved)
K->>A: deliver decision
A->>A: apply the change
A->>K: publish agent.results.completed
K->>O: deliver result
O->>U: "Done — PR #482 opened for review"
Notice what's not in this diagram: no synchronous call ever blocks waiting on the human reviewer. The Code Agent's consumer thread is free the instant it publishes to agent.approvals.pending — it picks up other conversations while this one waits, however long the human takes. That's the pause point REST couldn't give us cleanly.
And because every one of those arrows is a persisted, offset-addressable Kafka message, we can replay this exact sequence for any conversation ID, months later, to answer "why did the agent decide to touch the payment module" — which is the question that actually gets asked after something goes wrong.
Failure Handling: Idempotency and Dead Letters
At-least-once delivery is the Kafka default, which means every consumer above must tolerate processing the same message twice. Two things make that safe:
Idempotency keys on every task and tool call. Each AgentTask and ToolCallRequested carries a stable taskId. Before doing real work, the consumer checks whether that ID was already processed (a small Redis or Postgres lookup, TTL'd to the topic's retention window) and short-circuits if so.
A DLQ per failure-prone step, not a shared one. A tool call that throws after three retries goes to agent.tool-calls.dlq with the original request and the exception, not into a shared catch-all — when you're triaging failures at 9am you already know which stage failed and don't want to filter through unrelated noise.
@Component
public class ToolExecutorWorker {
private final KafkaTemplate<String, Object> kafka;
private final ToolRegistry toolRegistry;
public ToolExecutorWorker(KafkaTemplate<String, Object> kafka, ToolRegistry toolRegistry) {
this.kafka = kafka;
this.toolRegistry = toolRegistry;
}
@RetryableTopic(
attempts = "3",
backoff = @Backoff(delay = 2000, multiplier = 2),
dltTopicSuffix = ".dlq"
)
@KafkaListener(topics = AgentTopicConfig.TOOL_REQUESTED, groupId = "tool-executor")
public void onToolCall(ToolCallRequested request, @Header(KafkaHeaders.RECEIVED_KEY) String conversationId) {
Object output = toolRegistry.execute(request.toolName(), request.args());
kafka.send(AgentTopicConfig.TOOL_COMPLETED, conversationId,
new ToolCallCompleted(request.taskId(), conversationId, request.toolName(), output));
}
@DltHandler
public void onToolCallFailed(ToolCallRequested request, Exception ex) {
System.err.println("Tool call permanently failed: " + request.toolName() + " — " + ex.getMessage());
}
}
Spring Kafka's @RetryableTopic handles the retry-with-backoff and the DLQ routing declaratively — you don't hand-roll retry loops per agent.
The Free Audit Trail Nobody Asked For, But Everybody Needed
This was the part we didn't plan for and ended up mattering most. Because every reasoning step lives on agent.thoughts, keyed by conversation ID, with 30-day retention, we can reconstruct the entire decision trail of any agent conversation just by re-consuming that topic from offset zero for a given key. No custom logging framework, no separate tracing system bolted on after the fact — the event log is the trace.
When a reviewer asks "why did the Code Agent think it needed to touch three files instead of one," the answer isn't in an APM dashboard, it's three Kafka messages, in order, with the LLM's own stated reasoning attached to each. That's a fundamentally different debugging experience than grepping six services' logs for a correlation ID that may or may not have propagated correctly.
When This Is Overkill
A single agent doing single-turn conversational Q&A — a support chatbot, a one-shot summarizer — does not need any of this. Kafka adds latency (message round-trip instead of a direct call), operational surface area (topics, consumer groups, partition counts to tune), and a genuinely harder debugging model for the simple cases where nothing is going wrong. If your agent count is one and your task doesn't branch, a direct LLM call in a normal request handler is not just simpler — it's correct. We reached for Kafka only once we had multiple agent types that needed to hand work to each other, needed independent scaling, and needed a pause point for human review. If you're not there yet, don't build for it preemptively — you can migrate the same way we did, one agent type at a time, starting with whichever pair of agents is currently the most tightly coupled.
Proof, Not Promises: Every Snippet Above Actually Compiles
Architecture posts are full of code that looks right and was never run. I didn't want this to be one of them, so before publishing I pulled every class above into a real Maven project — the actual AgentEvents records, AgentTopicConfig, ResearchAgentWorker, and ToolExecutorWorker — and compiled it against the real dependencies, not stubs.
The pom.xml — Spring Boot 3.3.5, real spring-kafka, real spring-ai-starter-model-openai (pulled from Maven Central + the Spring Milestones repo), targeting Java 21:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
<dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
<dependency><groupId>org.springframework.retry</groupId><artifactId>spring-retry</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId></dependency>
</dependencies>
The command:
mvn clean compile
The real output:
[INFO] Compiling 6 source files with javac [debug parameters release 21] to target\classes
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
Every annotation used above — @KafkaListener, @Header(KafkaHeaders.RECEIVED_KEY), TopicBuilder, @RetryableTopic, @DltHandler, and Spring AI's chatClient.prompt().system().user().call().entity(Class) fluent API — resolved and compiled cleanly against the real libraries. Two things called out as stubs in the original draft (alreadyProcessed(...) and loadPendingTask(...)) are implemented above with a real Set/ConcurrentHashMap pair rather than left as hand-waved method names — in production you'd back that with Redis so it survives a restart, but the logic itself is real and compiles.
[!NOTE]
What this build doesn't prove: it's a compile-time check, not an integration test. I don't have a Kafka broker or Docker daemon available in the environment I wrote this from, so the consumer loop's actual runtime behavior against a live broker — offset commits, redelivery on crash, real ChatClient responses from a model — is not covered here. The code is real and type-correct; the distributed-systems behavior described (redelivery, ordering-by-key, DLQ routing) is standard, well-documented Kafka/Spring Kafka behavior, not something this particular build run exercised end-to-end.
The Shape of It
Agents that only know topic names, not each other's addresses. A ReAct loop that's a state machine driven by message consumption instead of blocking calls. Human approval as just another topic, not a bolted-on exception. And an audit trail that fell out of the architecture for free instead of being built separately. None of this required a new framework — Spring Kafka, Spring AI, and a disciplined topic design got us the whole way there.