Conversation History
This guide continues from Getting Started and shows how to record conversation history and expose APIs for frontend applications.
Prerequisites
Starting checkpoint: View the code from the previous section at java/spring/examples/doc-checkpoints/02-with-memory
Make sure you’ve completed the Getting Started guide first. You should have:
- A working Spring Boot agent with the Memory Service starter
- Memory Service running via Docker Compose
- OAuth2 authentication configured
Understanding Memory vs History
There are two types of message storage in Memory Service:
- Agent Memory - Internal context window for the LLM, stored in the
contextchannel. This is what the agent uses to maintain context across messages. - Conversation History - What users see in the UI, stored in the
historychannel. This records the actual messages exchanged between users and the agent.
In the previous guide, we only added agent memory. To display conversation history in a frontend UI, we need to also record history.
Enable History Recording
The Memory Service starter provides ConversationHistoryStreamAdvisorBuilder which creates an advisor that automatically records user messages and agent responses to the history channel.
Update your controller to include the history advisor:
package com.example.demo;
import io.github.chirino.memoryservice.history.ConversationHistoryStreamAdvisorBuilder;
import io.github.chirino.memoryservice.memory.MemoryServiceChatMemoryRepositoryBuilder;
import io.github.chirino.memoryservice.security.SecurityHelper;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ChatController {
private final ChatClient.Builder chatClientBuilder;
private final MemoryServiceChatMemoryRepositoryBuilder repositoryBuilder;
private final ConversationHistoryStreamAdvisorBuilder historyAdvisorBuilder;
private final OAuth2AuthorizedClientService authorizedClientService;
public ChatController(
ChatClient.Builder chatClientBuilder,
MemoryServiceChatMemoryRepositoryBuilder repositoryBuilder,
ConversationHistoryStreamAdvisorBuilder historyAdvisorBuilder,
ObjectProvider<OAuth2AuthorizedClientService> authorizedClientServiceProvider) {
this.chatClientBuilder = chatClientBuilder;
this.repositoryBuilder = repositoryBuilder;
this.historyAdvisorBuilder = historyAdvisorBuilder;
this.authorizedClientService = authorizedClientServiceProvider.getIfAvailable();
}
@PostMapping("/chat/{conversationId}")
public String chat(@PathVariable String conversationId, @RequestBody String message) {
String bearerToken = SecurityHelper.bearerToken(authorizedClientService);
var chatMemoryAdvisor =
MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder()
.chatMemoryRepository(repositoryBuilder.build(bearerToken))
.build())
.build();
var historyAdvisor = historyAdvisorBuilder.build(bearerToken);
var chatClient =
chatClientBuilder
.clone()
.defaultSystem("You are a helpful assistant.")
.defaultAdvisors(historyAdvisor, chatMemoryAdvisor)
.defaultAdvisors(
advisor ->
advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.build();
return chatClient.prompt().user(message).call().content();
}
} What changed: A ConversationHistoryStreamAdvisorBuilder is injected and used to create a historyAdvisor that is registered alongside the existing memory advisor. The history advisor is listed first so it intercepts both the outgoing user message and the incoming AI response.
Why: The memory advisor only writes messages to the internal context channel that the LLM uses for context; it does not produce entries visible in a frontend UI. The history advisor writes to the history channel, which is what the demo app (and any chat frontend) reads to display the conversation thread to the user.
Run your agent again:
mvn spring-boot:run
Test it with curl:
curl -NsSfX POST http://localhost:9090/chat/564f4b5f-789c-473b-adbf-44ed85de5550 \
-H "Content-Type: text/plain" \
-H "Authorization: Bearer $(get-token)" \
-d "Give me a random number between 1 and 100." Example output:
Sure! Here's a random number between 1 and 100: **42**. This time when you browse to to the demo agent app at http://localhost:8080/?conversationId=564f4b5f-789c-473b-adbf-44ed85de5550 you should see the messages that were exchanged between you and the agent.
Expose Conversation APIs
The Memory Service starter auto-configures a MemoryServiceProxy bean that makes it easy to expose conversation APIs to your frontend.
Create a REST controller that proxies requests to Memory Service:
package com.example.demo;
import io.github.chirino.memoryservice.client.MemoryServiceProxy;
import io.github.chirino.memoryservice.client.model.Channel;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/v1/conversations")
class MemoryServiceProxyController {
private final MemoryServiceProxy proxy;
MemoryServiceProxyController(MemoryServiceProxy proxy) {
this.proxy = proxy;
}
@GetMapping("/{conversationId}")
public ResponseEntity<?> getConversation(@PathVariable String conversationId) {
return proxy.getConversation(conversationId);
}
@GetMapping("/{conversationId}/entries")
public ResponseEntity<?> listConversationEntries(
@PathVariable String conversationId,
@RequestParam(required = false) String afterCursor,
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String channel,
@RequestParam(required = false) String epoch,
@RequestParam(required = false) String forks) {
Channel channelEnum = channel != null ? Channel.fromValue(channel) : Channel.HISTORY;
return proxy.listConversationEntries(
conversationId, afterCursor, limit, channelEnum, epoch, forks);
}
@GetMapping
public ResponseEntity<?> listConversations(
@RequestParam(value = "mode", required = false) String mode,
@RequestParam(value = "afterCursor", required = false) String afterCursor,
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "query", required = false) String query) {
return proxy.listConversations(mode, afterCursor, limit, query);
}
} What changed: A new MemoryServiceProxyController is introduced, mapping to /v1/conversations. It injects the auto-configured MemoryServiceProxy bean and exposes three endpoints: GET /{conversationId} to fetch a single conversation, GET /{conversationId}/entries to list its messages (filtered by channel, cursor, and epoch), and GET / to list all conversations with pagination and query filtering.
Why: Frontend SPAs and mobile clients need to query conversation data directly without going through the agent’s chat endpoint. By proxying these calls through your Spring Boot app, the Memory Service never needs to be exposed to the public internet, and the MemoryServiceProxy handles auth forwarding — it injects the service-account API key for the backend leg while propagating the user’s Bearer token for access control.
The MemoryServiceProxy also handles proper error handling and response mapping.
Test it with curl:
curl -sSfX GET http://localhost:9090/v1/conversations/564f4b5f-789c-473b-adbf-44ed85de5550 \
-H "Authorization: Bearer $(get-token)" | jq Example output:
{
"id": "564f4b5f-789c-473b-adbf-44ed85de5550",
"title": "Give me a random number between 1 and 10",
"ownerUserId": "bob",
"createdAt": "2026-03-06T14:59:34.475934Z",
"updatedAt": "2026-03-06T14:59:35.253296Z",
"lastMessagePreview": null,
"accessLevel": "owner",
"forkedAtEntryId": null,
"forkedAtConversationId": null
} curl -sSfX GET http://localhost:9090/v1/conversations/564f4b5f-789c-473b-adbf-44ed85de5550/entries \
-H "Authorization: Bearer $(get-token)" | jq Example output:
{
"data": [
{
"id": "2c490144-1894-40a0-aa62-bb034abb15bd",
"conversationId": "564f4b5f-789c-473b-adbf-44ed85de5550",
"userId": "bob",
"channel": "history",
"epoch": null,
"contentType": "history",
"content": [
{
"role": "USER",
"text": "Give me a random number between 1 and 100."
}
],
"createdAt": "2026-03-06T14:59:34.477525Z"
},
{
"id": "9364dcd7-9afc-436e-a6d2-a9fb040daed3",
"conversationId": "564f4b5f-789c-473b-adbf-44ed85de5550",
"userId": "bob",
"channel": "history",
"epoch": null,
"contentType": "history",
"content": [
{
"role": "AI",
"text": "Sure! Here's a random number between 1 and 100: **42**."
}
],
"createdAt": "2026-03-06T14:59:35.253296Z"
}
],
"afterCursor": null
} Expose Conversation Listing API
The MemoryServiceProxyController already includes the listConversations method (shown in the full code above). This endpoint supports:
- Pagination via
after(cursor) andlimitparameters - Search via the
queryparameter for filtering conversations - Mode for different listing modes (e.g.,
owned,shared)
Test it with curl:
curl -sSfX GET http://localhost:9090/v1/conversations \
-H "Authorization: Bearer $(get-token)" | jq Example output:
{
"data": [
{
"id": "564f4b5f-789c-473b-adbf-44ed85de5550",
"title": "Give me a random number between 1 and 10",
"ownerUserId": "bob",
"createdAt": "2026-03-06T14:59:34.475934Z",
"updatedAt": "2026-03-06T14:59:35.253296Z",
"lastMessagePreview": null,
"accessLevel": "owner"
}
],
"afterCursor": null
} Completed Checkpoint
Completed code: View the full implementation at java/spring/examples/doc-checkpoints/03-with-history
Next Steps
Continue to:
- Indexing and Search — Add search indexing and semantic search to your conversations
- Conversation Forking — Branch conversations to explore alternative paths
- Response Recording and Resumption — Streaming responses with resume and cancel support