Why We Built a Kotlin Agent Harness Instead of Using One

In early 2026, the JVM finally had serious options for building LLM agents. Koog, from JetBrains, arrived with a graph DSL, a GOAP planner, and multi-platform reach. LangChain4j brought its chain-based orchestration model to Kotlin and Java alike. Spring AI matured into a clean provider abstraction that plugged straight into Boot.
We evaluated all three. Then we opened a new file and typed
package dev.sophi.core.
This article explains why. It’s the first in a series documenting the build of SoPhi, a Kotlin agent harness designed to embed cleanly in Spring Boot services. Before we write about how SoPhi works, it’s worth being honest about the decision that shaped everything downstream: what we built ourselves, what we borrowed, and why the line between those two sits where it does.
What an agent harness is (and isn’t)
Strip away the tooling, the UIs, and the marketing, and an agent loop is short enough to say out loud:
prompt → LLM → optional tool calls → tool results → LLM → responseThat’s it. That loop, repeated until the model produces a final answer instead of another tool call, is the entire mechanical core of an “agent.”
Everything else — session persistence, context compaction, skill loading, plugin systems, streaming output, a terminal UI — is infrastructure around that loop, not the loop itself. It’s necessary infrastructure. SoPhi needs all of it. But it’s a separate concern, and conflating the two is where most framework evaluations go wrong.
Most JVM agent frameworks bundle the loop and the infrastructure into a single opinionated package. That’s a reasonable design choice for a framework meant to serve many use cases. It’s a worse fit when the harness itself is the product you’re shipping, and its behavior — not a library’s — is what your users depend on.
The JVM landscape in mid-2026
Here’s a fair account of where things stood when we made this call.
Koog is the most architecturally ambitious option. It offers a graph-based DSL for composing agent workflows, a GOAP (Goal-Oriented Action Planning) planner for multi-step reasoning, native Kotlin Multiplatform targets, support for the A2A (agent-to-agent) protocol, and LiteRT for on-device Android inference. It is genuinely Kotlin-native, not a Java library with Kotlin bindings bolted on. For teams building multi-agent systems that need to run across platforms, it’s a strong foundation.
LangChain4j brings the LangChain model — chains, opinionated tool orchestration, a broad ecosystem of integrations — to the JVM. It’s mature, well-documented, and a sensible default if you want orchestration patterns decided for you.
Spring AI takes a narrower, more useful-to-us position: it doesn’t try to own the agent loop at all. It’s a provider abstraction layer — auto-configured ChatModel beans for Claude, GPT, Gemini, and others, wired through Boot starters. No graph DSL, no planner, no opinion about how you structure an agent. Just clean, idiomatic access to model providers.
Each does what it does well. The question wasn’t which is “best” — it was which one’s assumptions matched ours.
What we actually needed
SoPhi has a specific, non-negotiable set of requirements, and they ruled things out one by one.
A specific JSONL session format. SoPhi’s session files need to follow an exact, self-defined format — not “close enough,” but precise, because interoperability with existing tooling depends on it. This means owning the session serialization format completely. No framework exposes this level of control because no framework was built around it.
A custom TUI. SoPhi ships its own terminal interface with its own rendering and event model. This isn’t something any of the three frameworks provide, so it’s custom work regardless of what sits underneath.
Lazy skill loading. Skills — the packaged capabilities an agent can draw on — need to load on demand, not eagerly at startup. This is a design decision specific to how SoPhi manages context and cost, not a general framework feature.
Clean Spring Boot embedding. SoPhi is embedded inside Spring services. The dependency graph, the bean lifecycle, and the startup behavior all need to follow Spring conventions without friction or adapter layers.
No graph DSL tax. SoPhi runs a single, well-defined loop. A graph DSL and a GOAP planner solve problems SoPhi doesn’t have — multi-agent coordination, dynamic goal replanning — and they don’t solve problems for free. Every unused capability in a dependency is still a capability you have to understand, upgrade, and reason about when something goes wrong.
Run those five requirements against Koog and LangChain4j, and the gap between “what the framework wants to give us” and “what we need” gets wide fast. Neither is doing anything wrong. Neither was built for this shape of problem.
The 600-line argument
Here’s the part that sounds like hubris and isn’t: the agent loop itself is small.
Prompt in, model call out, check for tool calls, execute them, feed results back, repeat until done. This is a well-specified, short program — the kind of thing that takes days to write correctly, not months. Our estimate, laid out in ADR-001, put a working core at 600–800 lines, growing to roughly 2,000 once compaction and hooks are included.
That number matters because it changes the shape of the decision. “Write it yourself” is a bad instinct when the thing you’d write is large, hard to get right, or genuinely commoditized — nobody should write their own JSON parser or TLS implementation in 2026. It’s a defensible instinct when the thing is small, well-specified, and central enough to your product that outsourcing it means outsourcing your product’s actual behavior.
There’s also a framework tax to account for, and it’s not just dependency weight. It’s the cost of understanding someone else’s abstractions well enough to bend them to a shape they weren’t designed for, and the cost of every future bug report starting with “is this SoPhi or is this Koog?” When the harness is the product, that ambiguity is expensive in a way it isn’t for a typical application dependency.
So the core decision, recorded in ADR-001, was to write sophi-core from scratch rather than build it on Koog — while leaving the door open. If SoPhi later needs graph-based multi-agent orchestration or A2A protocol support, Koog gets re-evaluated then, against a real requirement instead of a hypothetical one.
| ADR-001 concern | Verdict |
|---|---|
| Agent loop complexity | Small enough to own (~600 lines) |
| Harness-is-the-product risk | Avoided by owning session format, event shape, tool contract |
| Framework tax (graph DSL, GOAP, A2A, KMP, LiteRT) | Not needed for JVM, single-agent, Spring Boot target |
| JSONL session format | Must be exact and self-owned; not exposed by Koog |
| Skill system, TUI | Custom regardless of underlying framework |
| Future multi-agent orchestration | Re-evaluate Koog if the requirement materializes |
What we kept: Spring AI
Writing the loop ourselves doesn’t mean writing everything ourselves. The one piece we deliberately did not build is provider HTTP clients.
Spring AI 2.0’s Boot starters give SoPhi auto-configured ChatModel beans for Claude, GPT, Gemini, and Ollama, with a BOM that tracks provider SDK versions in one place. Writing and maintaining equivalent adapters — auth, retries, streaming, rate limits, provider-specific quirks — is weeks of ongoing work that produces no product value. Nobody chooses an agent harness because of how well it re-implements an HTTP client.
This is the flip side of the ADR-001 argument, recorded in ADR-002: provider adapters are not the product, so they’re exactly the kind of thing worth taking off the shelf. Spring AI stays contained to the sophi-ai module — sophi-core talks only to a LLMProvider Interface and has zero Spring AI imports. If Spring AI is ever replaced, the blast radius is one module, not the whole codebase.
LangChain4j was considered for this role too and rejected, not because it’s a bad library, but because it’s opinionated about orchestration in ways that would have fought SoPhi’s custom loop at every turn. We wanted a provider layer with no opinions about agents. Spring AI is exactly that.
Where this leaves us
Two decisions, one shape: own the things that define SoPhi’s behavior, borrow the things that don’t.
- sophi-core — the agent loop, session format, event model, tool contract — is entirely ours, because it is SoPhi.
- sophi-ai — provider HTTP access — is Spring AI, because provider adapters are infrastructure, not identity.
The next article in this series covers the module architecture that this split produces, and how sophi-core and sophi-ai talk to each other without either one leaking into the other.
What’s next?
This is the first part of the Building SoPhi series. In the next article, we’ll move from philosophy to the concrete: I’ll be showing how we designed the module boundaries between sophi-core and sophi-ai to ensure the harness remains portable and testable.
Find the full source on GitHub and connect with me on LinkedIn.