Building SoPhi · Article 02

Designing SoPhi: Eight Modules and the Decisions Behind Them

Shukri Shukriev · · Also on Medium

Illustration for Designing SoPhi: Eight Modules and the Decisions Behind Them

Good architecture is the set of decisions you don’t have to revisit. Before writing any agent code for SoPhi, we spent a week defining eight module boundaries and their contracts. This article is that week, compressed: what we drew, and why each line is where it is.

If article 01 was about what we chose not to build, this one is about how we organized what we did.

Start with the interface, not the implementation

The temptation with any agent harness is to start at the loop and work outward. We did the opposite. SoPhi needs to be reachable from four surfaces — a CLI, a web frontend over REST, a live session over WebSocket, and an embedded SDK for services that want SoPhi in-process. Rather than design the loop first and bolt surfaces on afterward, we designed the one thing all four surfaces would have to agree on: AgentSession.

AgentSession is the abstraction every surface talks to. It doesn't know whether it's driving a terminal, answering an HTTP request, streaming over a socket, or being called directly from Kotlin code inside someone else's Spring service. It exposes a session lifecycle, a way to send input, and a way to subscribe to events. That's the whole contract.

Drawing this boundary first — before any module beneath it existed — did something useful: it forced every module below AgentSession to be interface-clean. If a surface can only ever talk to AgentSession, then nothing below it can leak Spring types, servlet types, or terminal-rendering concerns upward. The interface came first because it's the thing that had to stay stable while everything underneath it was still being decided.

The module map

Eight modules, each with a job it owns and a boundary it doesn’t cross.

Illustration for Designing SoPhi: Eight Modules and the Decisions Behind Them

Walking it top to bottom:

sophi-cli owns the command-line entry point. It does not own rendering — that's sophi-tui — or the loop — that's sophi-core. It exposes a main() and consumes AgentSession plus sophi-tui's renderer.

sophi-web owns two of the four surfaces: REST endpoints and the WebSocket transport. It does not own session state, event shape, or the agent loop — those come from sophi-core untouched. It exposes HTTP and WebSocket endpoints, each a thin adapter over AgentSession. This is also the module where "WebSocket" and "RPC" are the same thing in practice: the WebSocket surface is SoPhi's RPC transport, carrying the same AgentSession events that the REST surface exposes as request/response.

sophi-sdk owns the embedding contract for services that want SoPhi in-process rather than over a network call. It does not own any Spring configuration — that's the point of it. It exposes a plain Kotlin API over AgentSession that a host application constructs and drives directly.

sophi-core owns the agent loop, the JSONL session format, the event model, the tool contract, and the plugin SPI. It does not own anything provider-specific, anything rendering-specific, or anything transport-specific. It exposes AgentSession, the loop itself, and the interfaces every other module implements against — LLMProvider, the tool contract, the plugin SPI.

sophi-ai owns provider access — the Spring AI-backed implementations of LLMProvider for Claude, GPT, Gemini, and Ollama. It does not own anything about how the loop uses those responses. It exposes implementations of an interface sophi-core defines, which is why the dependency arrow points from sophi-ai into sophi-core, never the other way.

sophi-skills owns skill discovery and lazy loading — resolving which skills are relevant to a session and loading them on demand rather than eagerly. It does not own how a loaded skill is invoked once resolved; that's still the loop's job. It exposes a skill loader that sophi-core calls into.

sophi-tui owns terminal rendering and input handling for the CLI surface. It does not own session state or the loop. It exposes a renderer that subscribes to AgentSession events and a means of capturing terminal input.

sophi-infra owns cross-cutting concerns that have nothing to do with agent behavior: authentication and observability. It does not own anything in the request path that determines what the agent does — only who's allowed to ask, and what gets measured once they do. It exposes security filters and a Prometheus registry, consumed by sophi-web and nothing else.

Where Spring Boot lives (and doesn’t)

Only two of the eight modules carry Spring Boot as a first-class runtime dependency.

sophi-web pulls in the Spring web and WebSocket starters, because it is the Spring-facing surface — that's its entire reason to exist. sophi-infra adds spring-boot-starter-security and micrometer-registry-prometheus, because auth and observability are the kind of cross-cutting concerns Spring's ecosystem handles well, and there's no reason to reinvent either.

Every other module — sophi-core, sophi-ai, sophi-skills, sophi-tui, sophi-cli, sophi-sdk — is plain Kotlin and coroutines. No Spring context, no bean scanning, no auto-configuration magic.

This is what keeps sophi-sdk genuinely lightweight to embed. A team building a plain Kotlin service, or a Spring service that doesn't want a second application context bootstrapped inside it, can pull in sophi-sdk and get an AgentSession without dragging in a servlet container, a security filter chain, or a metrics registry they didn't ask for. Spring Boot is where it earns its keep — the web surface, auth, observability — and absent everywhere it would just be dead weight.

The plugin boundary

SoPhi’s plugin system uses the JVM’s built-in ServiceLoader mechanism rather than Spring's @Bean and component-scanning model. This was a deliberate choice, not a default.

ServiceLoader plugins are zero-configuration: a JAR on the classpath with the right META-INF/services entry is discovered automatically, no Spring context required to find it. That matters because sophi-core — where the plugin SPI lives — has no Spring dependency at all, and plugin discovery shouldn't be the thing that forces one in. A plugin JAR dropped into a CLI installation, a plain-Kotlin sophi-sdk embedding, and a full sophi-web deployment all get discovered the same way, by the same mechanism, whether or not Spring happens to be on the classpath that day.

Spring’s @Bean scanning is excellent for wiring application-internal components. It's a worse fit for a plugin boundary meant to work identically with and without Spring present, because it makes plugin discovery conditional on infrastructure the plugin author shouldn't need to know about.

What the diagram doesn’t show

A module map tells you what depends on what. It doesn’t tell you three things worth stating explicitly.

Dependency direction only ever points inward. Surfaces (sophi-cli, sophi-web, sophi-sdk) depend on sophi-core. Implementations (sophi-ai, sophi-skills, sophi-tui) depend on sophi-core. Nothing insophi-core is depending on any of them. This isn't just tidy — it's what makes sophi-core testable in isolation and safe to reason about without loading the rest of the system into your head.

Coroutine dispatcher ownership sits with sophi-core. The loop decides what runs on what dispatcher. sophi-web's servlet threads and WebSocket handlers hand off into sophi-core's coroutine scope rather than the other way around, which means the loop's concurrency behavior doesn't change depending on which surface is driving it.

The session format is the only shared serialization contract. Every other module talks to sophi-core through Kotlin interfaces, in-process, with no serialization involved. The JSONL session format exists for one reason — persistence and replay — and nothing outside sophi-core reads or writes it directly. Surfaces get session state through AgentSession, not by parsing session files themselves.

Built as it’s drawn

None of this is worth much if the build system doesn’t reflect it. SoPhi is a Maven multi-module project — a decision recorded in ADR-003 — with a parent POM importing the Spring Boot, Spring AI, and Kotlin coroutines BOMs in one place. Module boundaries in the diagram above are Maven module boundaries in the repository: eight modules, eight pom.xml files, dependency direction enforced the same way in the reactor as it is in the diagram.

What’s next?

The next article in this series goes into sophi-core itself — the loop, the event model, and what the 600 lines from article 01 actually contain

Find the full source on GitHub and connect with me on LinkedIn.

All articles in the series