Skip to main content

Koog: JetBrains’ AI Agent Framework in Java, Your First Agent with Tools (and Spring Boot)

Koog 1.3.0 in plain Java: a two-tool agent, the exact HTTP requests it sends, what happens when a tool throws or a model loops, a strategy graph with a Java step before the model, and the Spring Boot starter with the Kotlin BOM trap. Tested against a scripted OpenAI-compatible endpoint; every transcript is committed.

Most Java agent tutorials start with a chat model and a prompt. Koog starts one layer higher: it is the part that runs the loop around the model, the part that calls your Java methods when the model asks, feeds the results back, and stops when the model answers. JetBrains built it for Kotlin and added a fluent Java API in 2026, so you can use it without writing a line of Kotlin. This post builds one small agent in plain Java, then looks at what it really does. A two-tool agent first. Then the exact HTTP requests it sends, because that is where the framework stops being magic. Then what happens when a tool throws, when the model sends nonsense, and when the model never stops. Then the same agent as an explicit graph with an ordinary Java step in front of the model, and finally inside Spring Boot, where one Maven detail costs an afternoon if you do not know it.
Tested against a scripted model, not a real one. There is no OpenAI key in the companion module. A small local HTTP server speaks the chat-completions format and answers from a script, and Koog’s real OpenAI client is pointed at it. Everything on the Koog side runs for real: the agent loop, the tool definitions it derives, argument parsing, the HTTP calls. What a real model would decide is not tested, and nothing here is a statement about how GPT-4o or Claude behaves with these tools.
Versions. Koog koog-agents-jvm 1.3.0 (GA on Maven Central; the artifact metadata was last updated 2026-09-23), Spring Boot starter koog-spring-boot-starter 1.3.0-beta (the docs label the Spring integration beta), Spring Boot 3.5.16, Kotlin 2.3.10, JDK 25, JUnit 6.1.3. Versions checked on Maven Central on 2026-11-11. The starter is built against Spring Boot 3.5.x; I did not test it on Spring Boot 4.

An agent is a loop, and Koog owns the loop

A chat model on its own answers a question with text. It cannot look up whether the checkout service is healthy, because it cannot run code. A tool fixes that: you describe a function to the model (name, purpose, parameters), and instead of guessing, the model can reply “please call this function with these arguments”. Your program runs the function and sends the result back. The model then writes its answer using the real value. Repeat until the model replies with plain text. That repeat-until-done cycle is the agent loop. Writing it by hand is a page of code, and then you add timeouts, error handling, history, tracing, retries. Koog is that page, done and tested, plus a way to describe the loop as a graph when “call the model until it stops asking for tools” is not the shape you need.
Your JVM processKoog agent loop+ your Java tools The modeldecides, never executes 1. messages + tool definitions 2. reply: text, OR a tool call getServiceStatus() 3. Koog runs the Java method, appends the result, goes back to step 1 The loop ends when the model answers with text. The model never touches your code; Koog does.
The picture carries the one fact to hold on to: the model only ever asks. Step 3 happens in your JVM, with your classes, your database connections and your permissions. When something goes wrong later in this post, it will almost always be a mismatch between what the model asked for and what your method accepts.
Going deeper: Koog and Spring AI sit on different layers JetBrains describes the relationship as layered: Spring AI is about connecting to models, embeddings and vector stores; Koog is the agent runtime on top (JetBrains’ Koog page). In practice that means you can write the same two-tool agent in either, and the difference is how much loop you write yourself. The agentic patterns post shows what the “write the loop yourself” side looks like with Spring AI’s ChatClient.

The smallest agent that calls a tool

Two dependencies. koog-agents-jvm is the framework; the -jvm suffix is how Kotlin Multiplatform libraries name their JVM artifact, and it is the one the Koog quickstart tells Maven users to add. Koog needs JDK 17 or newer. This module uses JDK 25. A tool is an ordinary method on a class that implements the ToolSet marker interface. @Tool marks the method, and @LLMDescription is the text the model reads when deciding whether to call it. The descriptions are not decoration: they are the only documentation the model gets. The full class is ServiceTools.java; here are its two tools.
@LLMDescription("Read-only questions about the health of our services")
public class ServiceTools implements ToolSet {

    private static final Map<String, String> STATUS = Map.of(
            "checkout", "DEGRADED",
            "search", "HEALTHY",
            "billing", "HEALTHY");

    private static final Map<String, Double> ERROR_RATE = Map.of(
            "checkout", 4.2,
            "search", 0.1,
            "billing", 0.3);

    @Tool
    @LLMDescription("Returns the current status of one service: HEALTHY, DEGRADED or DOWN")
    public String getServiceStatus(
            @LLMDescription("Service name, for example checkout") String service) {
        String status = STATUS.get(service);
        if (status == null) {
            throw new IllegalArgumentException("Unknown service: " + service);
        }
        return status;
    }

    @Tool(customName = "error_rate")
    @LLMDescription("Returns the percentage of failed requests for one service over the last N minutes")
    public double getErrorRate(
            @LLMDescription("Service name, for example checkout") String service,
            @LLMDescription("Window in minutes") int minutes) {
        Double rate = ERROR_RATE.get(service);
        if (rate == null) {
            throw new IllegalArgumentException("Unknown service: " + service);
        }
        return rate;
    }
}
Notice customName = "error_rate" on the second tool. Without it the model sees the method name; with it, the name you chose. The first tool keeps its method name, and the test below asserts both. The agent is a builder call: an executor (how to reach a model), a model, a system prompt, and a registry of tools. From OpsAgents.java:
public static AIAgent<String, String> firstAgent(PromptExecutor executor) {
        return AIAgent.builder()
                .promptExecutor(executor)
                .llmModel(OpenAIModels.Chat.GPT4o)
                .systemPrompt(SYSTEM_PROMPT)
                .toolRegistry(ToolRegistry.builder().tools(new ServiceTools()).build())
                .build();
    }
Running it is agent.run("Is checkout healthy?"), which blocks and returns a String. The test FirstAgentTest.java builds the executor against a scripted endpoint (from TestExecutors.java), so the OpenAI client talks to 127.0.0.1 instead of api.openai.com:
static PromptExecutor openAI(ScriptedOpenAIServer model) {
        var settings = new OpenAIClientSettings(
                model.baseUrl(), new ConnectionTimeoutConfig(),
                "v1/chat/completions", "v1/responses", "v1/embeddings", "v1/moderations", "v1/models");
        return PromptExecutor.builder().openAI("test-key", settings).build();
    }
}
From Java I used the seven-argument constructor and passed the standard paths. Against the real OpenAI you would write PromptExecutor.builder().openAI(System.getenv("OPENAI_API_KEY")).build() and leave the settings out.
Going deeper: the dependency block and what it brings in The module declares the framework and the starter like this (pom.xml):
<dependency>
      <groupId>ai.koog</groupId>
      <artifactId>koog-agents-jvm</artifactId>
      <version>${koog.version}</version>
    </dependency>
    <dependency>
      <groupId>ai.koog</groupId>
      <artifactId>koog-spring-boot-starter</artifactId>
      <version>${koog.beta.version}</version>
    </dependency>
Koog is a Kotlin library, so kotlin-stdlib, kotlinx-coroutines and kotlinx-serialization arrive transitively. That is harmless in a plain Java project and a trap inside Spring Boot; the Spring Boot section covers it. The agent’s run method is a blocking Java wrapper over a Kotlin suspend function, which is why a Java caller sees an ordinary method. There is a Kotlin DSL too, and Koog also targets JS, WasmJS, Android and iOS; none of that matters for a JVM backend.

What Koog actually sends to the model

One agent.run call, two HTTP requests. This is the first output worth reading closely, because every cost, every latency number and most bugs follow from it.
agent.run(“Is checkout healthy?”) = two HTTP calls POST /v1/chat/completions (1) system, usertools: getServiceStatus, error_rate→ reply: tool_calls getServiceStatus POST /v1/chat/completions (2) system, userassistant: tool_callstool: “DEGRADED”tools: (the same two again)→ reply: “checkout is DEGRADED” Koog calls getServiceStatus(“checkout”) Between the two calls your Java method ran. The model is stateless, so every call re-sendsthe whole history and the tool list. That is what you pay tokens for.
The model is stateless. After your method runs, Koog does not “continue” the first call; it makes a second one that repeats the whole conversation and the full tool list, plus the assistant’s tool call and your tool’s result. A longer tool chain means more requests, each larger than the last. This is the transcript, captured by the test (01-first-agent-on-the-wire.txt):
agent.run("Is checkout healthy?") -> checkout is DEGRADED

HTTP calls the model endpoint received: 2
  POST /v1/chat/completions
  POST /v1/chat/completions

Request 1 - messages:
  system: You are an on-call assistant. Use the tools to answer; never guess a status.
  user: Is checkout healthy?
The tool definitions Koog derived from the two annotated methods, as the model received them:
Request 1 - tools Koog derived from ServiceTools:
  {"name":"getServiceStatus","description":"Returns the current status of one service: HEALTHY, DEGRADED or DOWN","parameters":{"type":"object","properties":{"service":{"description":"Service name, for example checkout","type":"string"}},"required":["service"]}}
  {"name":"error_rate","description":"Returns the percentage of failed requests for one service over the last N minutes","parameters":{"type":"object","properties":{"service":{"description":"Service name, for example checkout","type":"string"},"minutes":{"description":"Window in minutes","type":"integer"}},"required":["service","minutes"]}}
Everything here came from your code: the descriptions are your @LLMDescription strings, service is a string because the Java parameter is a String, and minutes is an integer and is required because it is an int. Koog reads the method signature through reflection. The parent POM compiles with -parameters; I did not test without it, so I cannot say whether it is required. The second request carries the tool call and its result (same transcript, 01-first-agent-on-the-wire.txt):
  {"role":"system","content":"You are an on-call assistant. Use the tools to answer; never guess a status."}
  {"role":"user","content":"Is checkout healthy?"}
  {"role":"assistant","tool_calls":[{"id":"call_1","function":{"name":"getServiceStatus","arguments":"{\"service\":\"checkout\"}"},"type":"function"}]}
  {"role":"tool","content":"\"DEGRADED\"","tool_call_id":"call_1"}
Look at the tool message: its content is "DEGRADED" with the quotes. Koog serialises tool results as JSON, so a String arrives quoted and a double arrives as a bare number (the Spring Boot test asserts the content 4.2). A real model copes with either; if you ever write assertions against a scripted model, expect the quotes.
Going deeper: reading the cost off this transcript Because each request re-sends the history, input tokens grow with every tool round. A three-tool chain re-sends the system prompt and all tool definitions four times. Two practical consequences: keep tool descriptions short and specific, and register only the tools an agent needs, since every tool definition is paid for on every request. Koog has a prompt-cache module for the executor layer; I did not test it here, so I cannot say what it saves. The test that produced this output also asserts the contract, so the build fails if Koog ever changes the wire shape: exactly two POST /v1/chat/completions calls, two tools in the first request, the customName winning over the method name, and the tool message content being the JSON-quoted string. See FirstAgentTest.java.

When a tool throws, the model hears about it

The happy path is short. The days go to the unhappy ones, and an agent has a new kind of unhappy path: the caller of your method is a model, and models send arguments that no human would. I scripted four misbehaviours and recorded what Koog does with each (03-failure-modes.txt, written by FailureModesTest.java).
1. The tool throws IllegalArgumentException("Unknown service: nope")
   agent.run returned: There is no such service.
   LLM calls: 2
   what the model was told: tool: Tool with name 'getServiceStatus' failed to execute due to the error: Unknown service: nope!

2. The model sends {"minutes":"soon"} for an int parameter
   what the model was told: Tool with name 'error_rate' failed to parse arguments due to the error: Failed to parse literal '"soon"' as an int value at element: $.primitive | JSON input: "soon"
First: an exception inside a tool does not bubble out of agent.run. Koog catches it, turns it into text, and sends it to the model as the tool result, so the model gets a chance to recover (here it answers “There is no such service.”). That is friendly, and it has a cost you should know about: the exception message is shown to the model, and to whatever the model writes next. Do not put secrets, SQL or stack-derived detail in tool exception messages. Koog also logs the full stack trace at ERROR, which is why the test turns ai.koog logging off. Second: bad argument types are caught before your method runs, and the parse error goes back to the model the same way. Your method is never called with garbage. The third case behaves differently (03-failure-modes.txt, case 3):
3. The model calls restartService, which is not registered
   the history sent back, last two messages:
     {"role":"assistant","content":"{\"tool_call_id\":\"c1\",\"tool_name\":\"restartService\",\"tool_args\":{\"service\":\"checkout\"}}"}
     {"role":"user","content":"{\"tool_call_id\":\"c1\",\"tool_name\":\"restartService\",\"tool_result\":\"Tool with name 'restartService' not found in the tool registry. Use one of the available tools.\"}"}
An unregistered tool name is not sent back as a tool message. The history comes back as plain JSON text inside an assistant message and a user message. It still works, because the model can read it, but if you build log parsers or evaluation harnesses on the request bodies, this one case looks different from the other three. The fourth is the runaway (03-failure-modes.txt, case 4):
4. The model answers every request with another tool call, maxIterations(6)
   AIAgentMaxNumberOfIterationsReachedException
   LLM calls made before it stopped: 3
   default maxIterations of an agent you do not configure: 50
The iteration cap counts steps in the graph, not model calls. With maxIterations(6) the agent made three model calls before AIAgentMaxNumberOfIterationsReachedException stopped it, which fits each node execution (call the model, run the tool, send the result) counting as one iteration. The default is 50, so at the ratio seen here (three calls per six iterations) a runaway agent on the default settings would make about twenty-five model calls before it stops, and you pay for each. That figure is my extrapolation, not a measurement. Set the cap to the number of tool rounds you can justify.
Going deeper: reusing an agent instance A reasonable worry is whether an agent can be run twice. In 1.3.0 it can, and the second run starts from a clean history (04-agent-reuse.txt):
run 1: first
run 2: second
messages sent on run 2: 1
  {"role":"user","content":"hello again"}
The second request carried only the second user message; nothing from run 1 leaked in. That is convenient for a stateless request handler and the opposite of what you want for a chat. If you need conversation memory across calls, you have to carry it yourself or use Koog’s memory features, which I did not test. I also did not test two threads running one instance at the same time, so the Spring service below builds an agent per request, which sidesteps the question.

The same agent as a graph, with a Java step in front of the model

The builder in section two used Koog’s default strategy, which is the loop from the first picture. A strategy is that loop written down as a graph: nodes do work, edges say where to go next. You reach for a custom one when you need a step the model should not control: validating input, redacting it, checking a budget, routing. Here is a graph with one node of my own. It strips anything that looks like an API key from the user’s message before the model sees it.
start redact callLLM executeTool sendToolResult finish tool call text text tool call again Green boxes run your Java. Amber boxes call the model. redact is the only node we added.
Green boxes run your Java; amber ones call the model. redact is the only new node; the other four are the stock nodes that implement “call the model, run the tools, send the results back, repeat”. The dashed edges are the two exits taken when the model answers with text; the solid arc is the loop back for another tool call. In code, from OpsAgents.java:
public static AIAgentGraphStrategy<String, String> redactingStrategy() {
    var graph = AIAgentGraphStrategy.builder("redacting_single_run")
            .withInput(String.class)
            .withOutput(String.class);

    // A node is a function of (input, context). This one never talks to the model.
    var redact = AIAgentNode.builder("redact")
            .withInput(String.class)
            .withOutput(String.class)
            .withAction((input, ctx) -> input.replaceAll("sk-[A-Za-z0-9]{8,}", "[REDACTED]"))
            .build();

    var callLLM = AIAgentNode.llmRequest("callLLM");
    var executeTool = AIAgentNode.executeTools("executeTool");
    var sendToolResult = AIAgentNode.llmSendToolResults("sendToolResult");

    graph.edge(graph.nodeStart, redact);
    graph.edge(redact, callLLM);
    graph.edge(AIAgentEdge.builder().from(callLLM).to(executeTool).onToolCalls().build());
    graph.edge(AIAgentEdge.builder().from(callLLM).to(graph.nodeFinish).onTextMessage().build());
    graph.edge(executeTool, sendToolResult);
    graph.edge(AIAgentEdge.builder().from(sendToolResult).to(executeTool).onToolCalls().build());
    graph.edge(AIAgentEdge.builder().from(sendToolResult).to(graph.nodeFinish).onTextMessage().build());
    return graph.build();
}
A node is a function of (input, context), and the lambda receives them in that order. I wrote (ctx, input) first, because the Kotlin docs read that way, and the compiler reported that input was an AIAgentGraphContextBase. The edge builder reads like a sentence: from this node, to that one, when the reply is a tool call. Attaching the graph is one extra builder call:
public static AIAgent<String, String> redactingAgent(PromptExecutor executor) {
        return AIAgent.builder()
                .graphStrategy(redactingStrategy())
                .promptExecutor(executor)
                .llmModel(OpenAIModels.Chat.GPT4o)
                .systemPrompt(SYSTEM_PROMPT)
                .toolRegistry(ToolRegistry.builder().tools(new ServiceTools()).build())
                .build();
    }
The test (StrategyGraphTest.java, output 02-strategy-graph-and-redaction.txt) types a fake key and then checks every request body the model endpoint received:
The user typed:   My key is sk-ABCDEFGH12345678. Is checkout healthy?
The model got:    My key is [REDACTED]. Is checkout healthy?
Answer:           checkout is DEGRADED
Secret present in any request body: false
The secret is absent from every request, including the second one that re-sends the history, because the redaction happened at the start of the run. That is the point of a graph: a guarantee that holds because of the structure, not because a prompt asks nicely. Koog can draw the graph for you. MermaidDiagramGenerator.INSTANCE.generate(strategy) returns Mermaid source:
---
title: redacting_single_run
---
stateDiagram
    state "redact" as redact
    state "callLLM" as callLLM
    state "executeTool" as executeTool
    state "sendToolResult" as sendToolResult

    [*] --> redact
    redact --> callLLM
    callLLM --> executeTool : transformed
    callLLM --> [*] : transformed
    executeTool --> sendToolResult
    sendToolResult --> executeTool : transformed
    sendToolResult --> [*] : transformed
One quirk worth knowing before you paste this into a design document: every edge that carries a condition is labelled transformed, so callLLM --> executeTool (taken on a tool call) and callLLM --> [*] (taken on text) look identical. The generator shows the shape, not the conditions.
Going deeper: other ways to build a strategy AIAgentStrategies.chatAgentStrategy() is one of the predefined strategies and attaches with .graphStrategy(...). The builder offers functional strategies (plain code instead of nodes) and planner strategies too; the JetBrains announcement lists graph, functional and planning workflows. I only ran the graph form. Subgraphs, parallel node execution and conditional branching exist in the docs, but their examples are Kotlin only, and I did not port or test them. Edges can also transform a value in flight (.onCondition(...).transformed(...)), which is how you would reshape a tool result before the model sees it.

Spring Boot: the starter gives you executors, not agents

The Koog Spring Boot starter reads ai.koog.<provider>.* properties and registers a PromptExecutor bean per configured provider. It does not register an agent. That is the right split: an executor is a connection, and an agent is a configuration of tools and a prompt that is yours to decide. Here is the whole service (OpsService.java):
@Service
public class OpsService {

    private final PromptExecutor executor;

    public OpsService(@Qualifier("openAIExecutor") PromptExecutor executor) {
        this.executor = executor;
    }

    public String ask(String question) {
        AIAgent<String, String> agent = OpsAgents.firstAgent(executor);
        return agent.run(question);
    }
}
The test (SpringBootStarterTest.java, output 05-spring-boot-starter.txt) points ai.koog.openai.base-url at the scripted endpoint and calls the controller:
PromptExecutor beans the starter registered (only OpenAI is configured):
  multiLLMPromptExecutor
  openAIExecutor

GET /ask?q=How bad are checkout errors?
  -> checkout fails 4.2% of requests over 15 minutes
Model endpoint saw: [POST /v1/chat/completions, POST /v1/chat/completions]
Tool argument the model sent: minutes=15 (an int, parsed by Koog)

context.getBean(PromptExecutor.class) with one provider configured:
  NoUniqueBeanDefinitionException: expected single matching bean but found 2: multiLLMPromptExecutor,openAIExecutor
Two things in that output are worth a line of attention. First, one configured provider produced two executor beans, openAIExecutor and multiLLMPromptExecutor. Injecting a bare PromptExecutor therefore fails with NoUniqueBeanDefinitionException even with a single provider, which is why the service uses @Qualifier("openAIExecutor"). Second, no key means no bean, and no bean means the application does not start (06-spring-boot-no-api-key.txt):
ai.koog.openai.api-key is empty, ai.koog.openai.enabled=true is set:
  startup failed: true
  cause: NoSuchBeanDefinitionException
  message: No qualifying bean of type 'ai.koog.prompt.executor.model.PromptExecutor' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Qualifier("openAIExecutor")}
The failure is honest but it names the missing bean, not the missing property. If you see No qualifying bean of type PromptExecutor after adding Koog, check that the provider’s API key is actually set in that environment.
The Kotlin version trap. Spring Boot’s dependency BOM pins Kotlin. With Spring Boot 3.5.16 it pins kotlin-stdlib 1.9.25, coroutines 1.8.1 and kotlinx-serialization 1.6.3; Koog’s docs ask for Kotlin 2.3.10 and serialization 1.10.0. The BOM wins, and the first Koog call dies with a NoClassDefFoundError. Setting a <kotlin.version> property in your own POM does nothing here: that override only works through spring-boot-starter-parent, and an imported BOM does not read your properties. I confirmed this the hard way, with the property set and the old jar still resolving.
The fix is to import the Kotlin BOMs before the Spring Boot BOM, because Maven takes the first version it meets (pom.xml):
<!-- Order matters. Maven takes the FIRST version it meets, and the Spring Boot 3.5.16 BOM pins
           Kotlin 1.9.25, coroutines 1.8.1 and serialization 1.6.3. A <kotlin.version> property in this pom
           does not reach an imported BOM (only spring-boot-starter-parent honours it), so the Kotlin BOMs
           Koog needs are imported first. -->
      <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-bom</artifactId>
        <version>2.3.10</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>org.jetbrains.kotlinx</groupId>
        <artifactId>kotlinx-coroutines-bom</artifactId>
        <version>${kotlinx-coroutines.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>org.jetbrains.kotlinx</groupId>
        <artifactId>kotlinx-serialization-bom</artifactId>
        <version>${kotlinx-serialization.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
And this is what you get without it, reproduced by kotlin-bom-trap.sh with the same classpath and only the stdlib jar swapped (07-kotlin-bom-trap.txt):
kotlin-stdlib on the classpath (correct, Kotlin BOM imported first):
  kotlin-stdlib-2.3.10.jar

Same classpath with kotlin-stdlib 1.9.25, which is what the Spring Boot 3.5.16 BOM picks when it comes first:
  kotlin-stdlib-1.9.25.jar

$ java -cp <that classpath> com.ankurm.koog.TrapMain
  Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/time/Instant
  	at ai.koog.utils.time.KoogClock.<clinit>(KoogClock.kt:27)
  	at ai.koog.prompt.executor.model.PromptExecutorBuilder.openAI$default(PromptExecutorBuilder.kt:85)
  	at ai.koog.prompt.executor.model.PromptExecutorBuilder.openAI(PromptExecutorBuilder.kt)
The error names kotlin.time.Instant, not Spring or Maven, which is why it is easy to misread as a Koog bug.
Going deeper: the properties, and what I did not test Every provider (openai, anthropic, google, openrouter, deepseek, mistral, ollama) has enabled, api-key and base-url keys. Ollama is the exception: it needs an explicit enabled=true. The docs also say that setting the provider’s usual environment variable (such as OPENAI_API_KEY) is enough to switch it on. I tested only the OpenAI executor with a property-set key. The starter’s own POM is built against Spring Boot 3.5.14 and the docs list Spring Boot 3 as the requirement. This module ran on 3.5.16. I did not try Spring Boot 4, and the other modules in this repository use 4.x, so this module overrides the BOM version locally. Expect to check the starter’s release notes before moving a Boot 4 service onto it.

Where Koog fits next to Spring AI and Embabel

I have now built agents on all three in this series, so a short comparison. These are descriptions of what each is for, from their own docs and from the modules in the repository, not a benchmark.
KoogSpring AIEmbabel
LayerAgent runtime: loop, tools, strategiesModel, embedding and vector-store integrationGoal-oriented planning on the JVM
You describeTools and a graph (or use the default loop)Prompts and calls; you write the loopActions, goals and conditions; a planner chooses
Java APIFluent builders, added 2026, claimed parity with KotlinNativeNative
Spring BootStarter is beta; executors onlyFirst-classSpring-based
Lock-in to rememberKotlin runtime on your classpathSpringSpring and its planner model
The Koog row for the Java API is JetBrains’ own claim of full parity with the Kotlin side; I hit no gap in what I used, but I used little of it. They also say gaps can be bridged by calling the Kotlin core.
Should you use Koog? If your agent is “call a model, run one or two tools, answer”, you do not need it: Spring AI’s tool calling is less to carry, and you avoid a Kotlin runtime and a beta starter. Koog earns its place when the shape of the run is the problem: a step that must happen before the model, a budget that must stop it, several tool rounds you want to trace and test. Judge that on a real model first. Everything in this post used a scripted one, so it shows you the mechanics, the failure behaviour and the wire format, and it tells you nothing about how well a given model chooses your tools.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.