Conversation Forking
This guide covers conversation forking — letting users branch off from any point in a conversation to explore alternative paths.
New to forking concepts? Read Forking first to understand how conversation forking works. This guide focuses on the Spring implementation.
Prerequisites
Starting checkpoint: This guide starts from java/spring/examples/doc-checkpoints/03-with-history
Make sure you’ve completed the previous guides:
- Getting Started - Basic memory service integration
- Conversation History - History recording and APIs
Conversation Forking
How Forking Works
Forks are created implicitly when the first entry is appended to a new conversation with fork metadata. In this checkpoint, the ChatController accepts optional fork query params and forwards them to the history advisor:
this.historyAdvisorBuilder = historyAdvisorBuilder;
this.authorizedClientService = authorizedClientServiceProvider.getIfAvailable();
}
@PostMapping("/chat/{conversationId}")
public String chat(
@PathVariable String conversationId,
@RequestParam(required = false) String forkedAtConversationId,
@RequestParam(required = false) String forkedAtEntryId,
@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);
if (forkedAtConversationId != null
&& !forkedAtConversationId.isBlank()) {
advisor.param(
ConversationHistoryStreamAdvisor
.FORKED_AT_CONVERSATION_ID_KEY,
forkedAtConversationId);
}
if (forkedAtEntryId != null && !forkedAtEntryId.isBlank()) {
advisor.param(
ConversationHistoryStreamAdvisor
.FORKED_AT_ENTRY_ID_KEY,
forkedAtEntryId);
}
}) Why: Passing forkedAtConversationId and forkedAtEntryId through advisor context lets the same chat endpoint create either a root conversation or a fork, while still recording fork metadata on the first USER turn.
Listing Forks
Add this method to the MemoryServiceProxyController to list forks for a conversation:
@GetMapping("/{conversationId}/forks")
public ResponseEntity<?> listConversationForks(@PathVariable String conversationId) {
return proxy.listConversationForks(conversationId, null, null); What changed: A new GET /{conversationId}/forks endpoint is added to MemoryServiceProxyController, delegating to proxy.listConversationForks().
Why: Frontend UIs need to discover all conversations that branched off from a given point so they can render a fork tree or allow users to navigate between branches. Exposing this as a proxied endpoint keeps the Memory Service behind your Spring Boot app while giving the frontend everything it needs to build a branching conversation UI.
Try It With Curl
Define a helper to get a bearer token for bob:
function get-token() {
curl -sSfX POST http://localhost:8081/realms/memory-service/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=memory-service-client" \
-d "client_secret=change-me" \
-d "grant_type=password" \
-d "username=bob" \
-d "password=bob" \
| jq -r '.access_token'
}Create a turn on the source conversation:
curl -NsSfX POST http://localhost:9090/chat/a8391789-592d-4273-90df-7349b08b5d3d \
-H "Content-Type: text/plain" \
-H "Authorization: Bearer $(get-token)" \
-d "Hello from the root conversation." Example output:
Sure, I can help with that. Fetch the entry id to fork from:
curl -sSfX GET http://localhost:9090/v1/conversations/a8391789-592d-4273-90df-7349b08b5d3d/entries \
-H "Authorization: Bearer $(get-token)" | jq Example output:
{
"data": [
{
"id": "7b49adf0-ff34-4019-937d-258d9d154c93"
},
{
"id": "6a3d51d1-d4fb-482e-af40-74dfb9ae13e1"
}
],
"afterCursor": null
} Create the forked conversation by calling chat with fork metadata:
curl -NsSfX POST "http://localhost:9090/chat/6dcb8f3f-bd21-459f-9127-96e5a94d276c?forkedAtConversationId=a8391789-592d-4273-90df-7349b08b5d3d&forkedAtEntryId=${FORK_ENTRY_ID}" \
-H "Content-Type: text/plain" \
-H "Authorization: Bearer $(get-token)" \
-d "Continue from this fork." Example output:
{
"data": [
{
"id": "7b49adf0-ff34-4019-937d-258d9d154c93",
"conversationId": "a8391789-592d-4273-90df-7349b08b5d3d",
"userId": "bob",
"channel": "history",
"epoch": null,
"contentType": "history",
"content": [
{
"role": "USER",
"text": "Hello from the root conversation."
}
],
"createdAt": "2026-03-06T14:59:24.681546Z"
},
{
"id": "6a3d51d1-d4fb-482e-af40-74dfb9ae13e1",
"conversationId": "a8391789-592d-4273-90df-7349b08b5d3d",
"userId": "bob",
"channel": "history",
"epoch": null,
"contentType": "history",
"content": [
{
"role": "AI",
"text": "Hello! How can I assist you today?"
}
],
"createdAt": "2026-03-06T14:59:26.097352Z"
}
],
"afterCursor": null
} List forks for the source conversation through the Spring proxy endpoint:
curl -sSfX GET http://localhost:9090/v1/conversations/a8391789-592d-4273-90df-7349b08b5d3d/forks \
-H "Authorization: Bearer $(get-token)" | jq Example output:
{
"data": [
{
"conversationId": "a8391789-592d-4273-90df-7349b08b5d3d"
},
{
"conversationId": "6dcb8f3f-bd21-459f-9127-96e5a94d276c"
}
],
"afterCursor": null
} Next Steps
- Response Recording and Resumption - Streaming responses with resume and cancel support.
- Conversation Sharing - Share conversations with other users.
- Docker Compose Integration - Automatic memory-service container startup for development and testing.