An extensible AI agent harness written in Java. Direct Anthropic protocol over OkHttp, wired with Spring Boot 4, running across four surfaces (CLI · TUI · Web · WeChat) plus a cron scheduler. Bring your own key.
中文 README · Architecture deep-dive
Most agent runtimes today are Python. jooj is what happens when you build one seriously in Java — with 1160+ tests, 25 top-level packages by feature domain, and every component (@Component) auto-wired by Spring. It talks to Claude / Anthropic-compatible providers over raw HTTP, hosts an ecosystem of built-in tools plus MCP servers plus Claude-Code-style Skills, and exposes the same agent loop through a REPL, a JLine TUI, a web UI, WeChat, and a cron-triggered background lane.
If you've been reading Claude Code / Codex / Aider source code and wondering "what would this look like as a proper JVM app I could embed in a real product?" — this is that experiment.
User ─┐
Web ─┤
TUI ─┼── InboundDispatcher ──▶ AgentLoopHarness.processOneQuery(sid, q)
Cron ─┤ │
WeChat─┘ ├── Tools (14 built-in + MCP + Skills)
├── Compact / Memory / Transcript
├── Subagent (sync) / Teammate (async daemon)
└── Anthropic HTTP over OkHttp
- You want to learn how an agent runtime actually works. Recovery, compaction, memory, hooks, permission gates, MCP transport, skill loading, subagents — every piece is a small, testable class you can read in a sitting.
- You want Python-free. Long-running JVM process, no venv rot, boring deployment (
java -jar), first-class thread pools, real IDE support. - You want to embed an agent inside a Spring service. Because it is a Spring service. Every subsystem is a
@Component; you compose new tools, hooks, or channels by adding a class. - You want to inspect the wire. No official SDK, just an OkHttp client and typed DTOs —
logging.level.com.xilidou.jooj.http=DEBUGprints every request/response.
- Multi-turn Agent Loop with error recovery (
RecoveryCoordinatorhandles escalate / continuation / fatal internally, throws a checkedFatalRecoveryExceptionfor the loop to catch) - 14 built-in tools —
bash·read_file·write_file·edit_file·glob·todo_write·task_*·cron_schedule·team_message·worktree_*·mcp_manage·mcp_proxy·session_search·ask_user_question - MCP protocol client — call
connect_mcp("filesystem")at runtime, all remote tools instantly available asmcp__filesystem__readetc. Stdio and mock transports. - Skills system, three-layer with override priority:
<project>/skills/→~/.jooj/skills/→~/.claude/skills/. Compatible with vercel-labs/skills CLI ecosystem. - Persistent Memory — an LLM-driven extractor pulls durable facts (preferences, project state, references) after each turn; the selector re-injects only what's relevant on the next turn. Indexed with SQLite FTS5.
- Event-driven Transcript — the harness publishes
UserMessageReceived/AssistantResponseCompleted/TurnInterrupted/SessionDeleted/SessionHistoryClearedon Spring'sApplicationEventPublisher; independent listeners write jsonl transcripts, update FTS, clear todos. History and transcript are two separate concerns. - Two subagent models
- Subagent (synchronous): spawn, block, return summary — shares hooks / registry
- Teammate (async daemon): long-lived, independent inbox,
MessageBusrouting,git worktreeisolation
- Context compaction pipeline — four layers (Snip · Micro · Budget · History) applied proactively based on token budget
- Slash commands —
/clear·/sessions· plus customSlashCommand impl + @Component - Permission pipeline — deny-list → rule-based → user approval, all pluggable
- 1164 tests, ~8 seconds — including several
@SpringBootTestintegration cases
git clone https://github.com/YOUR_USERNAME/jooj.git && cd jooj
./mvnw -DskipTests=true package
# Launch the Web UI (default port 8080)
java -jar target/jooj-0.0.1-SNAPSHOT.jar --web
# ...or the TUI (JLine-based)
java -jar target/jooj-0.0.1-SNAPSHOT.jar --tui
# ...or plain CLI REPL
java -jar target/jooj-0.0.1-SNAPSHOT.jarFirst launch creates ~/.jooj/.env (mode 0600) with a template. Open it and drop in one of:
# Path 1 — Anthropic direct
ANTHROPIC_API_KEY=sk-ant-...
MODEL_ID=claude-sonnet-4-5
# Path 2 — any Anthropic-compatible proxy (LLM gateway, DeepSeek, self-hosted)
ANTHROPIC_AUTH_TOKEN=your-token
ANTHROPIC_BASE_URL=https://your-proxy.example.com
MODEL_ID=your-model-idEnvironment variables override the .env file, so export ANTHROPIC_API_KEY=... works too.
| Mode | Command | Best for |
|---|---|---|
| Web | java -jar ... --web → http://localhost:8080 |
Browser chat with sidebar showing skills / memory / status |
| TUI | java -jar ... --tui |
Rich terminal experience with live turn events |
| CLI | java -jar ... |
Plain REPL, scriptable |
Configure jooj.weixin.* → WeixinController |
Passive replies on WeChat Official Accounts | |
| Cron | cron_schedule tool or ~/.jooj/cron/scheduled_tasks.json |
Background scheduled agent runs, durable across restarts |
25 top-level packages, flat by feature domain (no deep nesting). Grouped into five clusters plus a Spring event bus:
graph TB
subgraph Entries["Entry Layer · 4 channels + cron"]
CLI[CLI REPL]
TUI[JLine TUI]
WEB[Web + SSE]
WX[WeChat]
CRON[Cron @Scheduled]
end
DISP[InboundDispatcher]
subgraph Core["Agent Core"]
ALH["AgentLoopHarness<br/>processOneQuery"]
RC["RecoveryCoordinator<br/>escalate + continuation"]
COMPACT["CompactPipeline<br/>Snip + Micro + Budget + History"]
PROMPT[SystemPromptAssembler]
SUB["Subagent (sync)<br/>Teammate (async daemon)"]
end
subgraph Domains["Domain Data · 8 packages"]
SESSION["session<br/>history + lock"]
TRAN["transcript<br/>jsonl append-only"]
MEM["memory<br/>extractor + selector"]
SEARCH["search<br/>SQLite FTS5"]
TASKS["tasks / todo"]
TEAM["team<br/>MessageBus + worktree"]
CRONX["cron<br/>scheduler + store"]
end
subgraph Tools["Tool Ecosystem"]
TOOL["14 built-in tools"]
MCP["MCP protocol client"]
SKILL["Skills 3-layer"]
HOOK["Hooks"]
PERM["Permission gates"]
end
subgraph Boundary["Boundary Layer"]
API["Anthropic / OpenAI HTTPS<br/>via OkHttp"]
FS["FileSystem + Bash"]
DISK["~/.jooj + .transcripts + .memory"]
end
EVENTS[["Spring EventBus · 7 event types<br/>UserMessageReceived · AssistantResponseCompleted<br/>TurnInterrupted · SessionDeleted · SessionHistoryCleared<br/>TurnEventPushed · PendingQuestionRegistered"]]
CLI --> DISP
TUI --> DISP
WEB --> DISP
WX --> DISP
CRON --> ALH
DISP --> ALH
ALH --> RC
ALH --> COMPACT
ALH --> PROMPT
ALH --> SUB
RC --> API
ALH -.publish.-> EVENTS
EVENTS -.listen.-> TRAN
EVENTS -.listen.-> SEARCH
EVENTS -.listen.-> TASKS
ALH --> TOOL
TOOL --> MCP
TOOL --> HOOK
HOOK --> PERM
ALH --> SKILL
ALH --> SESSION
ALH --> MEM
ALH --> TEAM
ALH --> CRONX
TOOL --> FS
TOOL --> API
MCP --> API
SESSION --> DISK
TRAN --> DISK
MEM --> DISK
SEARCH --> DISK
style ALH fill:#fce4ec,stroke:#c2185b,stroke-width:3px
style RC fill:#fce4ec
style SUB fill:#fce4ec
style EVENTS fill:#e1f5fe,stroke:#0277bd,stroke-width:2px
style TOOL fill:#e8f5e9
style MCP fill:#e8f5e9
style SKILL fill:#e8f5e9
style HOOK fill:#e8f5e9
style PERM fill:#e8f5e9
style API fill:#fff3e0
style DISK fill:#f3e5f5
Key runtime patterns:
- Dependency injection + event bus — the harness holds 17 collaborators via constructor injection, but domain-to-domain communication (transcript, search, todo cleanup) goes through Spring's
ApplicationEventPublisher. That way transcripts are decoupled from the loop. - Every channel funnels into one method —
AgentLoopHarness.processOneQuery(sid, query, hint). Memory prefetch,drainLeadInbox(pull teammate messages), andExecutionContextline up automatically. - Recovery internalised —
RecoveryCoordinator.call()loops onescalate/continuationinternally and only throwsFatalRecoveryException(checked) to the outer loop. No leaky sealed-type dispatch.
See Jooj项目_架构图.md for full mermaid diagrams, decision history (D1–D18), and the extension-point cheat sheet.
| I want to add… | I write… | I don't touch |
|---|---|---|
A new tool (e.g. git_diff) |
XxxTool implements Tool + @Component in tool/impl/ |
ToolRegistry, AgentLoopHarness |
| A new hook (e.g. rate-limiter) | Hook.OnPreToolUse + @Component in hook/impl/ |
HookManager |
| A new channel (Discord, Slack) | MessageChannel + @Component + matching AnswerPresenter |
InboundDispatcher, harness |
| A new event type | Add to TranscriptEvent sealed permits + @EventListener methods |
Harness (unless it's a new publish site) |
| A new MCP server | One line in application.yml under jooj.mcp.servers.* |
Any Java |
| A new skill | Drop a markdown file with frontmatter into skills/ |
SkillRegistry (auto-scans) |
| A new slash command | SlashCommand impl + @Component in slashcmd/impl/ |
Harness |
| A new permission rule | Edit jooj.permission.mode in yml — or write a PermissionGate bean |
Harness |
| Switching LLM backend (OpenAI etc.) | Implement AnthropicClient interface, swap the @Bean in HttpClientConfiguration |
Any core code |
The auto-wiring means you almost never touch the loop itself. That's the point.
jooj embeds the official Java Model Context Protocol SDK 2.0.0. Configure servers in application.yml:
jooj:
mcp:
servers:
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
git:
command: npx
args: ["-y", "@modelcontextprotocol/server-git", "--repo", "."]At runtime, the LLM can call connect_mcp("filesystem") — jooj spawns the stdio subprocess, discovers its tools, and exposes them as mcp__filesystem__read etc. Unknown server names fall back to an internal mock (handy for demos).
Chat (ChatController):
| Endpoint | Purpose |
|---|---|
POST /api/chat |
Send a query, run one loop turn, return {reply, historySize, toolCalls} |
GET /api/history |
Full flat history (role + text) |
POST /api/clear |
Clear session history |
Sidebar (SidebarController):
| Endpoint | Purpose |
|---|---|
GET /api/skills |
Skill catalog (name + description, no body) |
POST /api/skills/rescan |
Force skill directory rescan |
GET /api/memory |
Memory catalog as markdown |
GET /api/status |
Runtime status (model, cwd, tool count, skill count, cron count, memory size) |
Sessions (SessionController): list / switch / delete.
~/.jooj/ ← user-level jooj data (mode 0700)
├── .env ← API key / model (mode 0600, auto-created template)
├── cron/
│ └── scheduled_tasks.json ← durable cron jobs (survive restart)
└── skills/ ← user-level skills (shared across projects)
<project>/ ← current project directory
├── .memory/ ← project memory (MEMORY.md index + per-fact .md)
├── .tasks/ ← project task list
├── .mailboxes/ ← teammate file-based mailboxes
├── .transcripts/ ← event-driven session transcripts (jsonl)
├── .task_outputs/ ← large tool result overflow
├── .worktrees/ ← git worktree isolation for teammates
└── skills/ ← project-specific skills (checked in, team-shared)
Rule of thumb: secrets and cross-project capabilities live under ~/.jooj/. Anything tied to this work in this repo lives in the cwd.
| Principle | What it buys |
|---|---|
| One harness, many channels | CLI/TUI/Web/WeChat/Cron all funnel through processOneQuery — behaviour never diverges by surface |
| Direct Anthropic protocol | OkHttp + hand-rolled DTOs, no SDK. Every wire byte is inspectable at DEBUG log level |
| Spring Boot 4 auto-wiring | Adding a tool / hook / skill is one @Component — the framework finds it |
| Event-driven transcript | 7 event types decouple loop from persistence, FTS, todo cleanup. History and transcript are separate concerns |
| Interface-isolated boundaries | McpTransport, GitClient, AnthropicClient are interfaces with multiple implementations — mockable, swappable |
| Path separation | secrets + cross-project → ~/.jooj/; per-project state → cwd |
| Layer | Choice |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 4.1.0 |
| HTTP | OkHttp 5.4.0 (okhttp-jvm) |
| JSON | Jackson 2.18.2 (explicitly locked, not Boot's default 3.x) |
| MCP | Official Java SDK 2.0.0 |
| SQLite | sqlite-jdbc 3.47.2.0 (FTS5 for search) |
| TUI | JLine 3.30.15 |
| Testing | JUnit 5 + Mockito + AssertJ |
| Build | Maven (./mvnw) |
./mvnw test # ~1164 tests, ~8 secondsEvery top-level package has a dedicated test class: agent/, channel/, compact/, cron/, hook/, http/, mcp/, memory/, permission/, prompt/, session/, skill/, slashcmd/, subagent/, tasks/, team/, todo/, tool/, transcript/, web/, weixin/, bootstrap/.
Test setup mocks exactly one bean (MockAnthropicClient marked @Primary) — everything else is real Spring wiring.
# See the full Anthropic HTTP request/response bodies
./mvnw spring-boot:run \
-Dspring-boot.run.jvmArguments="-Dlogging.level.com.xilidou.jooj.http=DEBUG"
# See the full input to every tool call (default preview is 60 chars)
./mvnw spring-boot:run \
-Dspring-boot.run.jvmArguments="-Dlogging.level.com.xilidou.jooj.hook=DEBUG"
# Change port (default 8080)
./mvnw spring-boot:run \
-Dspring-boot.run.jvmArguments="-Dserver.port=8090"- ✅ Multi-channel agent loop with recovery (P0–P6)
- ✅ MCP client with stdio transport
- ✅ Skills system with three-layer override
- ✅ Persistent memory (extractor + selector + consolidator)
- ✅ Event-driven transcript with FTS search
- ✅ Two subagent models (sync Subagent + async Teammate with worktree isolation)
- ✅ TUI channel (JLine, s23)
- ✅ Vendor-neutral LLM domain (
llm/package, P2) - ⏳ OpenAI HTTP client (protocol adapter) — in progress
- ⏳ More MCP transports (SSE, HTTP)
- Anthropic Messages API — the wire protocol
- Model Context Protocol — MCP spec
- vercel-labs/skills — skill packaging CLI
- shareAI-lab/learn-claude-code — Python learning materials that inspired jooj's shape
PRs welcome. The extension-point table above should tell you exactly where to add a feature. If you're reading source to learn, start with:
AgentLoopHarness.processOneQuery— the one method every channel funnels intoRecoveryCoordinator.call— how errors get absorbedTranscriptService— how event listening decouples persistenceMemoryService— extractor + selector + consolidator
Give it a ⭐ if the runtime shape is useful — it helps others find it.
WTFPL — Do What The Fuck You Want To Public License, v2.
TL;DR: you just do what the fuck you want to.