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/quarkus/examples/doc-checkpoints/02-with-memory

Make sure you’ve completed the Getting Started guide first. You should have:

  • A working Quarkus agent with the Memory Service extension
  • Memory Service running via Docker Compose
  • OIDC authentication configured

Enable Conversation History Recording

In the previous guide, you added conversation memory, but messages don’t appear in the UI yet. That’s because we’re only storing agent memory, not the conversation history that users see.

To display conversation history in a frontend UI, wrap your agent with the @RecordConversation interceptor. This records both user messages and agent responses to the history channel (separate from the context channel used by agents).

Create a wrapper class:

HistoryRecordingAgent.java
package org.acme;

import io.github.chirino.memory.history.annotations.ConversationId;
import io.github.chirino.memory.history.annotations.RecordConversation;
import io.github.chirino.memory.history.annotations.UserMessage;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class HistoryRecordingAgent {

    private final Agent agent;

    @Inject
    public HistoryRecordingAgent(Agent agent) {
        this.agent = agent;
    }

    @RecordConversation
    public String chat(@ConversationId String conversationId, @UserMessage String userMessage) {
        return agent.chat(conversationId, userMessage);
    }
}

What changed: Created HistoryRecordingAgent, a CDI bean that wraps Agent and annotates the chat method with @RecordConversation, @ConversationId, and @UserMessage. Why: This wrapper separates the concern of history recording from the AI service interface. The @RecordConversation interceptor automatically saves the user’s message and the agent’s response to the Memory Service’s history channel, making them available for display in a frontend UI without any manual recording code.

Update ChatResource.java to inject HistoryRecordingAgent instead of Agent:

ChatResource.java
public class ChatResource {

    @Inject HistoryRecordingAgent agent;

What changed: The injected field was changed from Agent to HistoryRecordingAgent. Why: Routing calls through HistoryRecordingAgent instead of Agent directly activates the @RecordConversation interceptor, so every exchange is automatically persisted to the history channel without any additional code in the resource class.

Make sure you define a shell function that can get the bearer token for the bob user:

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'
}

Now test it again.

curl -NsSfX POST http://localhost:9090/chat/a2e24090-9de7-411b-8b50-e8b9d709ec9a \
  -H "Content-Type: text/plain" \
  -H "Authorization: Bearer $(get-token)" \
  -d "Give me a random number between 1 and 100."

Example output:

42

This time when you browse to to the demo agent app at http://localhost:8080/?conversationId=a2e24090-9de7-411b-8b50-e8b9d709ec9a you should see the messages that were exchanged between you and the agent.

Expose Conversation Entries API

To let the frontend load a conversation’s entry history, add a jackson dependency to enable JSON serialization/deserialization to the pom.xml:

pom.xml
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-rest-jackson</artifactId>
    </dependency>

What changed: Added the quarkus-rest-jackson dependency. Why: This enables automatic JSON serialization and deserialization for JAX-RS responses, which is needed for the conversation and entry list endpoints that return JSON from the Memory Service.

Then create a REST resource that proxies requests to Memory Service:

ConversationsResource.java
package org.acme;

import io.github.chirino.memory.client.model.Channel;
import io.github.chirino.memory.runtime.MemoryServiceProxy;
import io.smallrye.common.annotation.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/v1/conversations")
@ApplicationScoped
@Blocking
public class ConversationsResource {

    @Inject MemoryServiceProxy proxy;

    @GET
    @Path("/{conversationId}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getConversation(@PathParam("conversationId") String conversationId) {
        return proxy.getConversation(conversationId);
    }

    @GET
    @Path("/{conversationId}/entries")
    @Produces(MediaType.APPLICATION_JSON)
    public Response listConversationEntries(
            @PathParam("conversationId") String conversationId,
            @QueryParam("afterCursor") String afterCursor,
            @QueryParam("limit") Integer limit) {
        return proxy.listConversationEntries(
                conversationId, afterCursor, limit, Channel.HISTORY, null, null);
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response listConversations(
            @QueryParam("mode") String mode,
            @QueryParam("afterCursor") String afterCursor,
            @QueryParam("limit") Integer limit,
            @QueryParam("query") String query) {
        return proxy.listConversations(mode, afterCursor, limit, query);
    }
}

What changed: Created ConversationsResource with getConversation and listConversationEntries endpoints that delegate to an injected MemoryServiceProxy. Why: Agent apps should expose a subset of the Memory Service API to their frontends rather than giving clients direct access. The MemoryServiceProxy adds the service account API key for Memory Service authentication and passes through the caller’s Bearer token so the Memory Service can enforce per-user access control.

The Channel.HISTORY parameter ensures you get entries from the history channel (recorded by @RecordConversation) rather than the context channel (used by agents internally).

Test it with curl:

curl -sSfX GET http://localhost:9090/v1/conversations/a2e24090-9de7-411b-8b50-e8b9d709ec9a/ \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "id": "a2e24090-9de7-411b-8b50-e8b9d709ec9a",
  "ownerUserId": "bob",
  "accessLevel": "owner",
  "title": "Give me a random number between 1 and 100."
}
curl -sSfX GET http://localhost:9090/v1/conversations/a2e24090-9de7-411b-8b50-e8b9d709ec9a/entries \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "id": "a2e24090-9de7-411b-8b50-e8b9d709ec9a",
  "title": "Give me a random number between 1 and 10",
  "ownerUserId": "bob",
  "createdAt": "2026-03-06T14:59:00.741511Z",
  "updatedAt": "2026-03-06T14:59:01.42859Z",
  "accessLevel": "owner"
}

You should see the conversation and entries that were exchanged between you and the agent.

Expose Conversation Listing API

To let users browse all their conversations, add a listing method to ConversationsResource.java:

ConversationsResource.java
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response listConversations(
            @QueryParam("mode") String mode,
            @QueryParam("afterCursor") String afterCursor,
            @QueryParam("limit") Integer limit,
            @QueryParam("query") String query) {
        return proxy.listConversations(mode, afterCursor, limit, query);
    }

What changed: Added a listConversations endpoint that accepts mode, afterCursor, limit, and query query parameters and forwards them to MemoryServiceProxy. Why: This gives frontends a paginated, filterable view of a user’s conversations. The mode parameter can restrict results to conversations owned by the user or ones shared with them, while query enables keyword filtering without requiring a separate search index.

Test it with curl:

curl -sSfX GET http://localhost:9090/v1/conversations \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "data": [
    {
      "id": "cf233b9c-cd63-4dc3-83e2-20d17e0c9d9e",
      "conversationId": "a2e24090-9de7-411b-8b50-e8b9d709ec9a",
      "userId": "bob",
      "channel": "history",
      "contentType": "history",
      "content": [
        {
          "role": "USER",
          "text": "Give me a random number between 1 and 100."
        }
      ],
      "createdAt": "2026-03-06T14:59:00.744322Z"
    },
    {
      "id": "54d0a584-4914-41a4-8467-5624d4b2027d",
      "conversationId": "a2e24090-9de7-411b-8b50-e8b9d709ec9a",
      "userId": "bob",
      "channel": "history",
      "contentType": "history",
      "content": [
        {
          "role": "AI",
          "text": "Sure! The random number is 42."
        }
      ],
      "createdAt": "2026-03-06T14:59:01.42859Z"
    }
  ]
}

Completed Checkpoint

Completed code: View the full implementation at java/quarkus/examples/doc-checkpoints/03-with-history

Next Steps

Continue to: