Skip to main content

Your Agent's Planner Shouldn't Be a Language Model

· 21 min read
TheMCPGuy
MCP Developer & Educator

Series cover. A red LLM planner panel shows tangled arrows between tools, labelled invisible, non-reproducible and costly in tokens. A green GOAP planner panel shows an ordered chain from fetch to goal with preconditions and effects, labelled deterministic and auditable. The strapline reads: let the model think, let the planner plan.

Here is a question worth sitting with. Your agent has twelve steps it can take, MCP tool calls among them. A user asks for something that needs four of them, in a particular order. Who decides that order?

For almost every agent shipping today, the answer is: the language model. The same stochastic, temperature-sampled, occasionally-confidently-wrong component that we carefully sandbox, rate-limit, and never trust with raw SQL is also the component we hand the entire execution plan to. We validate its outputs obsessively. We rarely validate its plans, because the plan does not exist as an object you could validate.

There is another way to do this, it comes from video games, and it has a production release on the JVM.

Version disclosure (as of September 2026)
  • Embabel reached 1.0.0 on 2026-07-20 and is at 1.5.1, published 24 August 2026.
  • Since 1.5.0 the released line builds on Spring Boot 4.1.0, Spring AI 2.0.0 and Jackson 3, which is the stack the courses on this site target. The caveats section near the end has the version table.
  • Code here is structural, meant to show shape rather than to be pasted into production, and worth checking against the API of whichever version you pick up.

The part of your agent nobody tests

Run your agent twice on the same input. You will likely get two different tool orderings. Sometimes both work. Sometimes the second one calls summarize before anything has fetched the thing to summarize, gets an empty string, and cheerfully summarizes it.

This is not a prompt-engineering failure. It is structural. When the model is the planner:

  • The plan is invisible. Nothing exists for you to inspect, diff, or unit-test. There is a sequence of tool calls that happened to occur.
  • The plan is non-reproducible. Same input, same tools, different route. Debugging a bad run means reconstructing intent from a trace.
  • The plan costs tokens. Every planning decision is an inference call. Deciding what to do burns budget before you do anything.
  • Nothing proves it terminates. The model may loop forever, and the usual defense is a step budget, which is a timeout wearing a costume.
  • Preconditions live in English. "Only call post after you have content" sits in a system prompt, enforced by hope.

We accept this because the alternative used to be hand-written state machines, which are deterministic and also miserable: every new tool means new edges, and the graph rots.

But "LLM decides everything" and "I hand-code every transition" aren't the only options. That's a false binary, and games solved it in 2005.

What GOAP actually is

Goal-Oriented Action Planning comes from game AI: it's the technique behind enemies that appear to improvise. Instead of scripting behavior, you describe each action in terms of two things:

  • Preconditions: what must be true in the world before this action can run
  • Effects: what becomes true after it does

Then you declare a goal: a description of the world state you want. A planner searches the space of actions for a chain whose effects satisfy the goal, and whose preconditions are each satisfied in turn.

Three actions from a research agent, and the chain a planner finds:

Read the edge labels: summarize needs Content, only fetch produces it, so fetch goes first.

Crucially, the search is a classical algorithm, not an inference call. It's graph search over world states. Given the same actions and the same starting state, it returns the same plan, every time, in microseconds, for zero tokens.

The payoff is that behavior stays emergent without being unpredictable. Add a new action with honest preconditions and effects and the planner will route through it when useful: no edges to rewire, no prompt to rewrite. You get the adaptability people reach to LLMs for, with the determinism they gave up to get it.

And the plan becomes a real object. You can log it, diff it across runs, assert on it in a test, and show a user why step three happened.

Side by side against those five bullets:

QuestionLLM as plannerGOAP planner
Is there a plan object?The sequence of calls that happened, reconstructed afterwards from a traceA plan you can log, diff and assert on
Same input, same route?SometimesThe same actions and the same start state give the same plan
What does planning cost?One inference call per decisionClassical search, zero tokens
Does it terminate?A step budget stops itThe search either finds a chain to the goal or reports that none exists
Where do preconditions live?In the system promptIn the action signature, checked by the planner

Embabel: Spring's creator comes back for agents

Embabel is a JVM agent framework from Rod Johnson, the person who wrote the Spring Framework. It's implemented in Kotlin, deliberately Java-friendly, and built on top of Spring AI rather than in competition with it. Announcing the project, Johnson wrote: "Not since I founded the Spring Framework have I been so convinced that a new project is needed."

That "on top of Spring AI" detail is the one that matters for readers of this site. Embabel is not a rival stack asking you to abandon what you know. It's a planning layer above the thing the Spring AI + MCP course already teaches, and Spring AI is doing the model and transport work underneath.

Be clear about where the seam falls, though, because "it's built on Spring AI" can promise more continuity than it delivers.

ConcernWhose API you actually type against
Calling a model inside an @ActionEmbabel's Ai and PromptRunner, and not a ChatClient you assembled yourself
Choosing which model runsEmbabel's model registry
Metrics and tracesEmbabel's own observability starter
Talking MCP, as a client and as a serverSpring AI's MCP starters, underneath
Chat, vector store, retriesSpring AI, as a dependency you rarely touch directly

Spring AI is the infrastructure here, not the surface you write against.

The programming model is annotation-driven, which will feel immediately familiar:

@Agent(description = "Research a topic and publish a summary")
class ResearchAgent {

@Action
public Content fetch(UserInput input, Ai ai) { ... }

@Action
public Summary summarize(Content content, Ai ai) { ... }

@AchievesGoal(
description = "A published summary of the topic",
export = @Export(remote = true))
@Action
public Published post(Summary summary) { ... }
}

Notice what is absent: orchestration code. No if, no chain, no graph builder. Embabel infers actions, goals, and conditions from the data flow between method signatures: summarize needs a Content, and fetch produces one, so the planner knows fetch must precede it. The types are the preconditions and effects.

The planner then replans after each action rather than committing to one route up front, which is an OODA loop: observe, orient, decide, act. When an action returns something unexpected, the next plan accounts for it. This is the same instinct behind plan-execute-replan patterns in LLM agents, except the replanning step is free and deterministic.

The edge going back up to the planner is what separates this from a fixed pipeline. GOAP is the default, declared as plannerType = PlannerType.GOAP in ProcessOptions, and it is one of four in the PlannerType enum:

PlannerHow it picks what runs nextNeeds a goal
GOAPA* search for a chain of actions whose effects reach the goal. The defaultyes
UTILITYscores the available actions with utility functions and takes the bestno
HYBRIDtakes the highest-scoring action each tick, and stops as soon as any registered goal is satisfiedyes
SUPERVISORresolves to the same A* GOAP planner, and the enum does not describe it furtheryes

The "needs a goal" column is the enum's own needsGoals flag, and it explains the split. A utility planner picks the next best action without needing anywhere in particular to get to, so a goal is optional for it and required for the others.

To be clear about the division of labor: the LLM still does the things LLMs are good at. It extracts structure from messy input, writes the text, makes judgment calls inside an action. What it stops doing is deciding the shape of the workflow. That's the trade, and it's a better allocation of a stochastic resource.

The MCP part, which is better than I expected

I went in expecting MCP to be a checkbox. It isn't. Embabel is bidirectional, and the outbound half is the interesting one.

Consuming MCP servers is the obvious direction: an Embabel action can call tools from any MCP server, via an McpToolFactory. Point it at the Fetch server, a Brave Search server, a Confluence server, and those tools become available inside an action, attached through tool groups.

It is worth being precise about what that does and doesn't mean, because it is exactly the distinction this post rests on. MCP tools do not become nodes in the plan. The planner sequences @Action methods. Which MCP tool gets called inside a given action is still the model's choice, made in the usual reason-and-act loop: pick a tool, look at what comes back, pick the next one. So one agent has two decision-makers in it.

What gets decidedWho decides it
The order of the @Action methodsThe GOAP planner, before anything runs
Which MCP tool to call inside one actionThe model, in the reason-and-act loop
Whether to replan after an actionThe planner, after every action, always

You get determinism in the workflow, not in every leaf. Because Spring AI's MCP client is doing the transport work underneath, this is the same configuration surface the courses here already cover.

Exposing is where it gets good. Add one starter:

<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter-mcpserver</artifactId>
<version>1.5.1</version>
</dependency>

…and your agents can be published as MCP tools. Not your individual functions: your agents, goals and all. An entire multi-step planned workflow becomes one tool that any MCP client can call.

One catch, before you go looking for why nothing showed up: export is opt-in. @Export's remote() defaults to false, so the starter alone does not publish any of your goals. The goal has to say so, which is the export = @Export(remote = true) in the snippet above.

Sit with the recursion for a second. Claude calls one MCP tool. Behind that tool, a GOAP planner decomposes the request, routes through five actions, three of which call out to tools from other MCP servers, replanning as results arrive. The calling model sees a single clean tool call.

The sequencing of the actions between the first arrow and the last is deterministic and testable, even though the tool choices inside them are not.

That's a genuinely different shape from "expose 40 fine-grained tools and pray the model sequences them correctly". It is also a real answer to tool bloat: every tool definition a server advertises sits in the calling model's context on every call.

Twelve fine-grained toolsOne agent published as a tool
Tool definitions in the caller's contextTwelve, on every callOne, on every call
Who sequences themThe calling modelThe GOAP planner inside the server
What the host approvesTwelve invocations it can inspect one by oneOne invocation covering all twelve
What a bad run leaves youAn ordering you reconstruct from a traceA plan you can print

Two things to plan for before you publish an agent this way, because neither is decided for you.

A planned workflow takes as long as the plan takes. Five actions, three of them calling out to other MCP servers, is a synchronous tools/call that will meet whatever timeout the calling client applies. The protocol's answer is the Tasks extension, io.modelcontextprotocol/tasks, where the server returns a task handle and the client polls tasks/get until the work reaches a terminal state. Task creation is server-directed, so the decision to hand back a handle sits where the knowledge sits: with the side running the plan. Whether your framework and your target clients implement the extension is the thing to settle first. The Day MCP Stopped Watching the Clock covers the mechanics.

The server answers with a handle straight away, which keeps the client's timeout out of your planning budget.

One approval now covers the whole plan. The MCP specification says a human should always be able to deny a tool invocation. When the calling model's host approves that single tool call, it is approving everything behind it. Five actions run, some reaching third-party MCP servers, and the host sees one invocation and one result. That is the bargain any coarse-grained tool offers, and it is a good one while you control the agent and its tool groups. It is a poor one if those groups can be reconfigured by someone else, because the blast radius of that one approved call is every tool the agent can reach.

There is a starter aimed at exactly that risk:

  • embabel-agent-starter-mcpserver-security, published beside the server starter at the same 1.5.1 version.
  • Why you want it: Spring AI's HTTP server transports expose an unauthenticated JSON-RPC endpoint by default, so any client that can reach it can list and invoke every registered tool.

Embabel's MCP server starter supports three HTTP transports, all of them inherited from Spring AI's server starter underneath.

Transportspring.ai.mcp.server.protocolSession stateWhere it stands
SSESSEone session per clientdeprecated since Spring AI 2.0.0
Streamable HTTPSTREAMABLEone session per clientthe replacement for SSE
Stateless Streamable HTTPSTATELESSdropped between requestsscales sideways, at a price

That last row is not a small detail. A stateful MCP server ties each client to the single instance holding its session, which is what sticky sessions on a load balancer are there to guarantee. A stateless server drops that tie and scales horizontally like any other stateless web service. You pay in capability: it cannot send requests back to the client, so elicitation, sampling and ping are unavailable. The credit for that option belongs to Spring AI's starter, which Embabel configures instead of reimplementing. It is the row to pick if you intend to run more than one instance.

The honest caveats

I would rather flag these than have you discover them at mvn compile.

The stack under it moved a whole generation in three weeks. Every cell below comes from that row's POM on Maven Central.

EmbabelReleasedSpring BootSpring AIJackson
1.0.02026-07-203.5.141.1.72, com.fasterxml
1.5.02026-08-114.1.02.0.03, tools.jackson
1.5.12026-08-244.1.02.0.03, tools.jackson

1.5.0 landed as the Spring AI 2.0.0 GA migration, and Boot 3 to 4 is a real migration: Spring Framework 7, Spring Security 7, and Jackson 3 in place of Jackson 2. Doing that inside a month is good news, and a warning: expect the versions to move again.

The API is still moving. A 1.0 is a commitment to stability, and the 1.x releases so far are 1.0.0-RC1, 1.0.0, 1.5.0 and 1.5.1. Jumping from 1.0 to 1.5 in one step is what early stability looks like. Pin exact versions, and read the release notes before you upgrade.

GOAP is not free. You pay in modeling discipline. Actions need honest preconditions and effects, and if you lie about them, or model your domain sloppily, the planner will confidently produce a valid plan that does the wrong thing. The failure mode moves rather than disappearing:

When a run goes wrongWhat you have to debug
The model improvised badlyThe trace, the prompt, and whatever the model happened to do that run
Your world model was wrongThe preconditions and effects declared on the actions, which are code you can read and test

The second row is far easier to debug, but it is still work.

It suits some problems and not others. A three-step, always-identical pipeline does not need a planner; write the three calls. GOAP earns its keep when there are many actions, real preconditions, and genuine variability in which route is correct.

Kotlin under the hood. Java-friendly by design, and the annotation model above is Java, but stack traces and source-diving land you in Kotlin. Fine for most teams; worth knowing.

So should you use it?

If you're building JVM agents that coordinate a meaningful number of MCP tools, and you've ever been unable to explain why a run did what it did, then yes, this is worth a prototype. Especially in regulated domains, where "the model decided" is not an acceptable audit answer and a serializable plan very much is.

If you're on Spring Boot 4 today, take the released 1.5.1: it is built against Spring Boot 4.1.0 and Spring AI 2.0.0.

Two questions decide whether a planner is worth it:

The right-hand branch is where the argument stops being aesthetic. And if you keep one thing from this: notice that your planner is currently a language model, and ask whether it needs to be. That question is worth asking regardless of whether Embabel is the answer for you. The interesting frontier in agent design isn't better planning prompts. It's recognizing that some of the work we handed to models was not model-shaped to begin with.


New to this? Start with MCP Fundamentals for the protocol from first principles, then Building MCP Servers in Java to build one. If you want the Spring AI foundation Embabel is built on, the Spring AI + MCP course covers it end to end.

Further Reading

Sources