🤖 From Prompting to Planning: What Embabel Taught Me About Agents

📅 Thursday, Jul 16, 2026

⏰ 23:33:10+05:30


Goal-Oriented Action Planning: typed results accumulate on the blackboard until the goal is reachable

I first ran into Embabel at DevNexus and have been quietly using it ever since, but it wasn’t until a recent class — going deep with Dashaun — that it really hit home. I spent the last stretch working through an Embabel workshop — building a bounded “digital worker” that responds to production incidents — and it reorganized how I think about agents on the JVM. Most of the agent content I read treats the LLM as the brain: you write a clever prompt, hand the model some tools, and hope it strings them together. Embabel pushes the intelligence somewhere far more boring and far more trustworthy: into Java types, into a planner, and into policy that a compiler and a test suite can see. This post is my attempt to write down what I actually learned, how I’d classify Embabel, and why I’m setting aside my own platform, AgentFabric, and starting to use Embabel instead.

The one-sentence reframe

The line from the workshop that stuck with me was: the worker chooses the path, your code defines the world. That is the whole shift. In a normal service you write a method that calls four collaborators in a fixed order. In Embabel you declare the capabilities and the desired outcome, and a planner discovers the order at runtime from the current state. You stop writing the sequence and start describing the world the sequence lives in.

What Embabel actually is

If I had to classify Embabel in one phrase, I’d call it a neuro-symbolic, planning-first agent framework for the JVM. Let me unpack why, because each word is doing work.

It’s symbolic because the core engine is Goal-Oriented Action Planning (GOAP) — the same technique game AI has used for years. GOAP starts from the goal and reasons about which declared actions, given the current state, can reach it. The planner is deterministic. It is not the LLM. This is the part people miss: Embabel does not ask a model “what should I do next?” It computes the plan from types.

It’s neural because an individual action is free to call a model. The LLM lives inside a step, boxed in by the types around it, not sitting above the whole process pulling levers.

It’s planning-first because the mental model is OODA — Observe, Orient, Decide, Act — running as a loop. After every action produces a result (or fails), the planner re-observes the world and reassesses what is now possible. Failure isn’t an exception to swallow; it’s new information that changes the next plan.

And it’s JVM-native because Spring still owns everything. Embabel is a Kotlin framework with clean Java authoring, and your agent is still an ordinary @Component. Constructor injection, interfaces, mocks, tests, observability — none of it changes. Embabel just adds planning metadata on top of methods you’d have written anyway.

Types are preconditions and effects

Here’s the idea that made it click for me. Consider four action signatures from the incident worker:

ServiceObservation observeServices(IncidentRequest request)

RunbookAssessment applyRunbook(
    IncidentRequest request, ServiceObservation observation)

IncidentResponseReport analyzeIncident(
    IncidentRequest request,
    ServiceObservation observation,
    RunbookAssessment assessment)

IncidentWorkflowReport prepareReport(
    IncidentRequest request,
    ServiceObservation observation,
    RunbookAssessment assessment,
    IncidentResponseReport response)

Nobody writes the orchestration. The parameters are the preconditions and the return type is the effect. An action is applicable only when every parameter type already exists on the blackboard; running it deposits its return type, which unlocks the next action. So the plan isn’t authored — it falls out of the data flow:

IncidentRequest
   └─ observeServices ─→ ServiceObservation
        └─ applyRunbook ─→ RunbookAssessment
             └─ analyzeIncident ─→ IncidentResponseReport
                  └─ prepareReport ─→ IncidentWorkflowReport  (GOAL)

At the start only IncidentRequest exists, so only observeServices can fire. Each result makes exactly one more action eligible. I checked this against the workshop’s tiny planner, and the mechanism really is that literal: it filters methods whose parameter types are all present, picks the lowest-cost one, invokes it, and puts the result back on the blackboard. That’s it.

The blackboard is typed working memory

There’s no JSON router and no stringly-typed state machine. State is a set of domain objects — IncidentRequest, ServiceObservation, RunbookAssessment — that the debugger, the compiler, the tests, the logs, and the planner all see identically. When I’ve built agent-ish things in the past, the “state” was usually a bag of strings passed through prompts, and it was untestable by construction. Making state a typed blackboard means the invariants live in Java, not in prose I’m begging a model to respect.

DICE: context is a domain model, not a prompt

The workshop calls this DICE — Domain-Integrated Context Engineering — and it’s the philosophical core. Instead of stuffing everything into a prompt, you encode organizational knowledge as executable, testable Java first, then hand the model only the facts it needs to reason inside those boundaries.

The clearest example is approval. The runbook, in plain Java, decides whether a proposed production change requires human sign-off:

return switch (request.incidentType()) {
    case OUT_OF_MEMORY -> new RunbookAssessment(
        "Heap pressure is consistent with an OutOfMemory failure.",
        evidence,
        "Capture a heap dump, roll back the latest risky change, "
            + "then validate with a canary.",
        true);   // requiresApproval — always
    case HIGH_LATENCY -> new RunbookAssessment(
        "Database timeouts and cache misses indicate dependency saturation.",
        evidence,
        "Check database and cache health, then use an approved "
            + "rollback or scale-out.",
        true);
};

The model never sees a requiresApproval field it could flip. It can’t. The model’s output type doesn’t contain that field, and the goal step copies the deterministic value straight from the runbook. Policy is the compiler’s job; interpretation is the model’s job. That separation is the whole point.

Where Spring AI comes in

Spring AI is the thing doing the actual model call inside an action, and it’s exactly where I’ve spent most of my own time. The pattern is small and lovely:

return chatClient.prompt().user("""
    You are an SRE following a production runbook.

    Incident: %s
    Metrics: %s
    Logs: %s
    Runbook diagnosis: %s
    Approved strategy: %s

    Return concise analysis, diagnosis, and recommendation.
    Keep the recommendation inside the approved strategy.
    """.formatted(/* domain facts */))
    .call()
    .entity(IncidentResponseReport.class);

.entity(IncidentResponseReport.class) is the part I lean on constantly: the model reasons, but Java owns the schema. The reply comes back as a typed object or the action fails — and because it can fail, the plan needs a way to recover.

Failure is just another node in the graph

This was my favorite lesson, and it’s where planning earns its keep. The model call is the preferred path (low cost). If it throws — Ollama cold, provider down, invalid output — the planner doesn’t retry the same prompt and it doesn’t ask the model to improvise. It re-observes the blackboard, notices the request and runbook assessment are still true, and selects a different declared capability with the same effect type:

@Action(description = "Fall back to deterministic runbook output",
        readOnly = true, cost = 10.0)
public IncidentResponseReport fallBackToRunbook(
        IncidentRequest request, RunbookAssessment assessment) {
    return new IncidentResponseReport(
        "The model was unavailable; deterministic policy was retained.",
        assessment.diagnosis(),
        assessment.recommendedAction());
}

Two actions, same return type. Cost expresses preference (1.0 for the model, 10.0 for the fallback) without a hand-written if/else route. The blackboard makes the fallback applicable only after the preferred path fails. Plan repair, expressed as data rather than control flow. And the whole run explains itself afterward: a PlanExecution record lists completed actions, failed actions, and whether the goal was achieved — an audit trail of decisions and outcomes, not a dump of private chain-of-thought.

Autonomy only means anything inside boundaries

The worker is allowed to observe Compose, apply the runbook, ask the model, fall back, and prepare a report. It is not allowed to invent shell commands, restart production, bypass approval, or run forever. Two guardrails enforce the last one: actions are FIRE_ONCE by default (no silent re-running) and the planner has a hard step limit. A read-only worker that stops at human approval is still autonomous — autonomy is goal-directed selection inside a capability boundary, not unrestricted mutation. That framing alone is worth the price of admission.

Why I’m putting AgentFabric down and picking up Embabel

The reason all of this landed so hard is that I’ve spent a long time building AgentFabric — my own durable JVM agent platform on the Spring AI substrate — and Embabel put a name to the philosophy I’d been reaching for by feel. Having seen it done properly, I’m going to stop investing in AgentFabric and start using Embabel instead.

AgentFabric was my attempt to run agents in production, durably, across service boundaries. It stands on:

  • Spring AI as the model and tool layer — ChatClient, structured-output binding, and MCP tool calling wired through a ToolCallbackProvider.
  • LangGraph4j for stateful graph topology — nodes that call the model, routers that branch on conditions, and verification loops that re-prompt until output passes a quality gate.
  • Temporal for durable execution — every graph run is a workflow, so a crashed agent resumes from the last completed node instead of replaying LLM calls.
  • A2A and MCP as the wire protocols — agent-to-agent messaging and agent-to-tool calls.
  • PostgreSQL as the source of truth for configuration, checkpoints, and token accounting.

It works. But being honest with myself, most of that is orchestration plumbing I wrote so I could get to the actual agent — and the hardest, most valuable part, the deliberation, is the part I did worst. In AgentFabric I describe the graph — nodes, edges, routers — as declarative topology, which means I’m still hand-authoring the sequence and then maintaining it forever. Embabel’s whole point is that I shouldn’t be doing that at all: declare capabilities as typed actions and a goal, and the planner derives the topology from data flow. The centerpiece of my platform turns out to be a worse version of something a maintained framework already gives me for free.

What convinced me isn’t that Embabel is different — it’s that everything I got right in AgentFabric, I got right by accidentally reinventing Embabel, badly:

  • My LangGraph4j state channels are a home-grown blackboard — except in Embabel the presence of a type is what makes the next step applicable, so I stop drawing edges entirely.
  • My verifier loop plus deterministic fallback is hand-wired plan repair — Embabel does it with a second declared action at a higher cost, selected automatically when the model action fails. No re-prompt path to maintain.
  • My completeAs(Class<T>) calls are just Embabel’s .entity(...) — both are Spring AI underneath, so this is the one place there’s genuinely nothing to migrate; it’s the same call.
  • My budget guard router is a clumsier cost-based action preference.
  • My guarded write tools (dry-run unless apply=true) are the same instinct as keeping requiresApproval in Java, never the model — one thing I’ll happily carry over as a habit rather than a codebase.
  • Even the durability I reached for Temporal to get is largely native: Embabel’s blackboard persists through a pluggable AgentProcessRepository — in-memory by default, but back it with PostgreSQL or MongoDB and a process survives a restart. And human-in-the-loop pause/resume is built in via awaitables: a process returns WAITING with a processId and you resume it later through /continue. I wrote a Temporal integration to get exactly these two things.

There’s a real cost to admitting this — AgentFabric is a lot of my work — but continuing to maintain a bespoke orchestration engine to avoid adopting a better, supported one is just ego with a build file. Embabel gives me the planner; Spring gives me the beans; Spring AI gives me the model calls I already knew; a persistent repository gives me durable state and pause/resume.

So do I still need Temporal? Almost never. The only case that would pull it back in is when I need guarantees Embabel’s step-level persistence doesn’t offer: deterministic replay with exactly-once activities (the JVM dies mid-LLM-call and must resume without re-invoking the model), durable timers (“wait three days, then continue”), or distributed orchestration across a worker fleet with guaranteed delivery and backoff. For a single-process agent that plans, calls a model, and pauses for human approval — which is most of what I actually build — none of that applies. So AgentFabric goes on the shelf, and my next agent starts as an Embabel @Agent.

And with the recent news that Embabel is heading for a 1.0.0 release, whatever hesitation I had about betting on it is gone. I’m all in.

What I’m taking away

The move Embabel names is the move from calling APIs to building workers. A worker navigates a typed domain, invokes real Spring-managed services, plans from preconditions and effects, stops at an explicit goal, repairs a failed path, preserves human approval, and leaves an audit trail. None of that requires trusting a model with the steering wheel. It requires giving the model a small, well-lit room to think in — and letting typed code define everything outside the door. That’s the bet I’m making by setting my own platform down and building on Embabel instead.

If you’re on the JVM and you’ve been building agents by growing ever-larger prompts, I’d genuinely recommend sitting with GOAP for an afternoon. It reframes the problem from “how do I make the model behave” to “what world am I asking it to operate in” — and the second question is one your compiler can help you answer.