A multi-agent AI conversation system where four distinct Singapore kopitiam regulars chat amongst themselves β orchestrated by an LLM, evaluated by a judge, and persisted across sessions.
Built on Spring Boot + LangChain4j + LangGraph4j.
| Persona | Age | Background | Speech Style | Tools |
|---|---|---|---|---|
| Uncle Ah Seng | 68 | 30+ years running the drinks stall. Pragmatic, thrifty, complains about costs. | Heavy Singlish β lah, lor, wah | time, weather |
| Mei Qi | 21 | Content creator promoting kopitiam online. Social media savvy, very chatty. | Mix of English and Singlish β OMG, yasss, emojis | time, news |
| Bala Nair | 45 | Ex-statistician turned football tipster. Sees patterns in everything. Dry humour. | Formal English with occasional Singlish, statistical references | time |
| Dr. Tan | 72 | Retired philosophy professor. Thoughtful, deep, sips his kopi-o slowly. | Measured English with philosophical tangents | (none) |
START
β
βΌ
[human_node] β Seeds the opening message + sets volley count
β
βΌ
[orchestrator_node] β LLM picks who speaks next
β
ββ volley > 0 βββΊ [participant_node] β Selected persona responds (ReAct loop)
β β
β ββββββββββββββββΊ [orchestrator_node] (loops)
β
ββ volley = 0 βββΊ [summarizer_node] β Generates conversation summary
β
βΌ
[evaluator_node] β LLM-as-Judge scores the conversation
β
END
| Node | Class | What It Does |
|---|---|---|
human_node |
inline in KopitiamGraph |
Seeds messages with the user's opening message and resets volley_msg_left |
orchestrator_node |
OrchestratorNode |
Calls GPT-4o-mini to select the next speaker based on conversation history |
participant_node |
ParticipantService |
Runs a ReAct loop β the selected persona may call tools before responding |
summarizer_node |
SummarizerNode |
Generates a narrative summary of the full conversation |
evaluator_node |
EvaluatorNode |
Scores the conversation on 3 dimensions and returns a JSON scorecard |
Every request carries an X-Session-Id header. The compiled graph is a Spring singleton with a MemorySaver checkpointer attached. Each session ID maps to its own checkpoint thread β so the same session ID on a follow-up request resumes the exact conversation state (message history, volley count, next speaker) from where it left off.
Request 1 X-Session-Id: abc β MemorySaver stores checkpoint["abc"]
Request 2 X-Session-Id: abc β MemorySaver restores checkpoint["abc"] β conversation continues
Request 3 X-Session-Id: xyz β No checkpoint["xyz"] β fresh conversation
Each persona has access to a subset of real-time tools, called via a simple TOOL:<name> protocol in the ReAct loop:
| Tool | Class | Returns |
|---|---|---|
time |
SingaporeTimeService |
Current Singapore time (SGT) |
weather |
SingaporeWeatherService |
Current Singapore weather summary |
news |
SingaporeNewsService |
Recent Singapore news headlines |
| Layer | Technology | Version |
|---|---|---|
| Language | Java | 21 |
| Framework | Spring Boot | 3.4.1 |
| Graph / Orchestration | LangGraph4j | 1.8.4 |
| LLM Client | LangChain4j | 1.1.0 / 1.1.0-beta7 |
| LLM Model | OpenAI GPT-4o-mini | β |
| Build | Gradle Kotlin DSL | β |
- Java 21+
- An OpenAI API key
The application requires an OPENAI_API_KEY environment variable. Without it the app will not start.
export OPENAI_API_KEY=sk-your-key-hereYou can also prefix it inline when running:
OPENAI_API_KEY=sk-your-key-here ./gradlew bootRunNote: Never paste your key into
application.ymldirectly. The file uses${OPENAI_API_KEY}so it always reads from the environment.
./gradlew build./gradlew bootRunOr, if you want to pass the API key inline without exporting:
OPENAI_API_KEY=sk-your-key-here ./gradlew bootRunThe app starts on http://localhost:8080.
curl http://localhost:8080/api/graph/invokeThe four regulars will chatter away. When done, you receive a JSON response:
{
"status": "completed",
"sessionId": "3f7a2b1c-49de-4a1b-bf23-9c1e7d8a0f22",
"evaluation": {
"character_consistency": { "score": 5, "reason": "Each agent stayed firmly in character throughout." },
"conversation_naturalness": { "score": 4, "reason": "The banter flowed organically with good topic transitions." },
"tool_usage_correctness": { "score": 5, "reason": "Tools were only invoked when real-time data was genuinely needed." },
"overall": 5,
"summary": "A lively and authentic kopitiam conversation with strong character voices and natural flow."
},
"message": "Conversation ended successfully. Come back anytime lah!"
}Starts a new kopitiam conversation or resumes an existing session.
| Parameter | Type | Where | Required | Default | Description |
|---|---|---|---|---|---|
X-Session-Id |
string |
Header | No | auto UUID | Session identifier. Reuse to resume a previous conversation. |
message |
string |
Query param | No | "Hello everyone! What's happening at the kopitiam today?" |
The opening message injected into the conversation. |
Example β new session with default greeting:
curl http://localhost:8080/api/graph/invokeExample β new session with a custom topic:
curl "http://localhost:8080/api/graph/invoke?message=What+do+you+all+think+about+the+new+MRT+line"Example β resume a previous session:
curl -H "X-Session-Id: 3f7a2b1c-49de-4a1b-bf23-9c1e7d8a0f22" \
"http://localhost:8080/api/graph/invoke?message=What+did+Ah+Seng+say+earlier"Example β pin your own session ID:
curl -H "X-Session-Id: my-kopitiam-session" \
http://localhost:8080/api/graph/invokesrc/main/java/org/example/
βββ Main.java
βββ config/
β βββ PersonaRegistry.java # Defines all 4 persona configs (name, background, tools)
βββ controller/
β βββ GraphController.java # GET /api/graph/invoke β session + message routing
β βββ PersonaController.java # Persona inspection endpoints
βββ graph/
β βββ KopitiamGraph.java # @Configuration β builds and exposes the compiled graph @Bean
β βββ KopitiamState.java # Shared state: messages (appender), volley_msg_left, next_speaker, evaluation
βββ model/
β βββ Persona.java # Persona data model
βββ nodes/
β βββ OrchestratorNode.java # Speaker selection via LLM
β βββ SummarizerNode.java # End-of-conversation summary
β βββ EvaluatorNode.java # LLM-as-Judge scoring node
βββ service/
β βββ GraphService.java # Invokes the compiled graph with per-session RunnableConfig
β βββ OrchestratorService.java # Speaker selection logic
β βββ ParticipantService.java # ReAct loop β persona responds, optionally calling tools
β βββ SummarizerService.java # Summary generation
β βββ EvaluatorService.java # Scores conversation on 3 dimensions, returns JSON scorecard
βββ tools/
βββ SingaporeNewsService.java # Returns Singapore news headlines
βββ SingaporeTimeService.java # Returns current Singapore time (SGT)
βββ SingaporeWeatherService.java # Returns Singapore weather
βββ ToolExecutor.java # Routes TOOL:<name> calls to the right service
All settings live in src/main/resources/application.yml:
| Key | Default | Description |
|---|---|---|
langchain4j.open-ai.chat-model.model-name |
gpt-4o-mini |
OpenAI model used by all agents |
langchain4j.open-ai.chat-model.temperature |
0.7 |
Creativity level for agent responses |
langchain4j.open-ai.chat-model.log-requests |
true |
Logs full HTTP request to OpenAI |
langchain4j.open-ai.chat-model.log-responses |
true |
Logs full HTTP response from OpenAI |
server.port |
8080 |
Port the app listens on |
Conversation length is controlled by DEFAULT_VOLLEYS in KopitiamGraph.java (default: 4 turns). Increase it for longer conversations.
After every conversation the evaluator_node calls GPT-4o-mini with a structured judge prompt and scores the exchange on three dimensions:
| Dimension | What It Measures |
|---|---|
character_consistency |
Did each agent stay true to their persona throughout? |
conversation_naturalness |
Did the conversation flow organically, like real kopitiam banter? |
tool_usage_correctness |
Were tools (time / weather / news) called only when genuinely needed? |
Each dimension gets a score of 1β5 with a one-sentence reason. An overall score (1β5) and a one-sentence summary verdict are also returned. The scorecard is embedded directly in the API response as structured JSON.
You: Hello everyone! What's happening at the kopitiam today?
[orchestrator] β selected: ah_seng
Ah Seng: Wah, today very hot lah! Must drink more kopi, keep awake lor.
You all try the new kaya toast or not? Very nice, leh!
[orchestrator] β selected: mei_qi
Mei Qi: OMG, I haven't tried it yet! π Must go get some later!
Anyone know if they serve it with half-boiled eggs? That's the best combo, yasss! π₯β¨
[orchestrator] β selected: bala
Bala: Ah, the new kaya toast β an intriguing variable in our breakfast equation.
If they serve it with half-boiled eggs, satisfaction levels would increase by a
significant margin, statistically speaking. Must try later, confirm!
[summarizer] β Kopitiam Conversation Summary:
Key topics: Hot weather, new kaya toast, ideal breakfast combos.
Dynamics: Casual and lively, mix of enthusiasm and dry analytical humour.
Mood: Light-hearted and jovial.
[evaluator] β Score: 5/5
"A lively and authentic kopitiam exchange with strong character voices and natural topic flow."
Alamak, what are you waiting for? Go run it lah! β