Spring Getting Started

This guide walks you through integrating Memory Service with a Spring Boot AI agent. You’ll start with basic chat memory and can progressively add features in the follow-up guides.

Make sure you’ve completed the prerequisites before starting this guide.

Step 1: Create a Simple Spring AI App

Starting checkpoint: View the complete code at java/spring/examples/doc-checkpoints/01-basic-agent

First, let’s create a new Spring Boot application. Use Spring Initializr to setup the project. Make sure to use Spring Boot 3.5 as that the latest version supported by Spring AI and add the OpenAI and Spring Web dependencies.

Once you download the starter project extract it and open it in your favorite IDE.

unzip demo.zip
cd demo

Create a simple REST controller:

ChatController.java
package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
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 chatClient;

    public ChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @PostMapping("/chat")
    public String chat(@RequestBody String message) {
        return chatClient.prompt().user(message).call().content();
    }
}

Why: This is the minimal wiring needed to call an LLM from a Spring Boot app. ChatClient is Spring AI’s fluent facade over the underlying model; injecting ChatClient.Builder lets Spring Boot auto-configure the OpenAI connection from properties, so the controller stays free of credential logic.

Configure the server port and OpenAI credentials in application.properties:

application.properties
server.port=9090
spring.ai.openai.api-key=${OPENAI_API_KEY:}
spring.ai.openai.base-url=${OPENAI_BASE_URL:https://api.openai.com}
spring.ai.openai.chat.options.model=${OPENAI_MODEL:gpt-4}

Why: Keeping credentials in environment variables rather than hardcoded values prevents secrets from being committed to source control and makes it straightforward to point the app at a different model or a local proxy without code changes.

Run your agent:

export OPENAI_API_KEY=your-api-key
mvn spring-boot:run

Test it with curl:

curl -NsSfX POST http://localhost:9090/chat \
  -H "Content-Type: application/json" \
  -d '"Hi, I'\''m Hiram, who are you?"'

Example output:

Hello Hiram! I'm an AI language model created by OpenAI, here to help answer questions and provide information on a wide range of topics. How can I assist you today?

Expected: The agent responds but has no memory of your name.

curl -NsSfX POST http://localhost:9090/chat \
  -H "Content-Type: application/json" \
  -d '"Who am I?"'

Example output:

That's a profound question! Identity can be multifaceted, encompassing aspects like your name, personal experiences, relationships, beliefs, and roles in society. If you're asking in a more abstract or philosophical sense, it might involve reflecting on your values, purpose, and what makes you unique. If you have specific aspects of your identity you're curious about or exploring, feel free to share more!

Let’s add conversation memory.

Step 2: Add Memory Service Starter

Starting checkpoint: View the complete code at java/spring/examples/doc-checkpoints/02-with-memory

Add the Memory Service Spring Boot starter and OAuth2 resource server dependencies to your pom.xml:

<dependency>
  <groupId>io.github.chirino.memory-service</groupId>
  <artifactId>memory-service-spring-boot-starter</artifactId>
  <version>999-SNAPSHOT</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Start the Memory Service in Docker Compose following the Getting Started guide.

Configure the connection to the Memory Service in application.properties:

memory-service.client.url=http://localhost:8082
memory-service.client.api-key=agent-api-key-1
memory-service.client.log-requests=true

Configure OAuth2 in application.properties. The client configuration supports browser-based login, while the resource server configuration enables Bearer token authentication for API clients:

# OAuth2 client configuration (for browser-based login)
spring.security.oauth2.client.provider.memory-service-client.issuer-uri=http://localhost:8081/realms/memory-service
spring.security.oauth2.client.registration.memory-service-client.client-id=memory-service-client
spring.security.oauth2.client.registration.memory-service-client.client-secret=change-me
spring.security.oauth2.client.registration.memory-service-client.scope=openid,profile,email
spring.security.oauth2.client.registration.memory-service-client.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.memory-service-client.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}

# OAuth2 resource server configuration (for Bearer token authentication)
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8081/realms/memory-service

Configure security:

SecurityConfig.java
package com.example.demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(
                        auth ->
                                auth.requestMatchers("/ready")
                                        .permitAll()
                                        .anyRequest()
                                        .authenticated())
                .oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/", false))
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
                .logout(logout -> logout.logoutSuccessUrl("/"));
        return http.build();
    }
}

What changed: A SecurityConfig bean enables both oauth2Login (browser session-based) and oauth2ResourceServer JWT validation (Bearer token) in a single filter chain, with CSRF disabled and every request requiring authentication.

Why: Agent apps typically need to serve two audiences at once — browser users who log in interactively via Keycloak, and API clients (curl, frontends, other services) that present a Bearer token. Enabling both modes in one chain means you don’t need separate endpoints or ports; Spring Security picks the right authentication mechanism based on the Authorization header present in each request.

Update the controller to use chat memory:

ChatController.java
package com.example.demo;

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 OAuth2AuthorizedClientService authorizedClientService;

    public ChatController(
            ChatClient.Builder chatClientBuilder,
            MemoryServiceChatMemoryRepositoryBuilder repositoryBuilder,
            ObjectProvider<OAuth2AuthorizedClientService> authorizedClientServiceProvider) {
        this.chatClientBuilder = chatClientBuilder;
        this.repositoryBuilder = repositoryBuilder;
        this.authorizedClientService = authorizedClientServiceProvider.getIfAvailable();
    }

    @PostMapping("/chat/{conversationId}")
    public String chat(@PathVariable String conversationId, @RequestBody String message) {

        String bearerToken = SecurityHelper.bearerToken(authorizedClientService);
        MessageChatMemoryAdvisor chatMemoryAdvisor =
                MessageChatMemoryAdvisor.builder(
                                MessageWindowChatMemory.builder()
                                        .chatMemoryRepository(repositoryBuilder.build(bearerToken))
                                        .build())
                        .build();

        var chatClient =
                chatClientBuilder
                        .clone()
                        .defaultSystem("You are a helpful assistant.")
                        .defaultAdvisors(chatMemoryAdvisor)
                        .defaultAdvisors(
                                advisor ->
                                        advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
                        .build();

        return chatClient.prompt().user(message).call().content();
    }
}

What changed: The endpoint now accepts a {conversationId} path variable, retrieves the authenticated user’s Bearer token via SecurityHelper.bearerToken(), builds a MessageWindowChatMemory backed by the Memory Service using MemoryServiceChatMemoryRepositoryBuilder, wraps it in a MessageChatMemoryAdvisor, and passes the conversation ID as an advisor parameter.

Why: MemoryServiceChatMemoryRepositoryBuilder.build(bearerToken) creates a repository that reads and writes the agent’s context window to and from the Memory Service under the current user’s identity, so every user’s conversation history is stored separately and securely. MessageWindowChatMemory keeps only the most recent N messages in the prompt, preventing the context window from growing unbounded across many turns.

Run your agent again:

export OPENAI_API_KEY=your-api-key
mvn spring-boot:run

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

Test it with curl—now with conversation memory:

curl -NsSfX POST http://localhost:9090/chat/2cdbd4b0-b48d-4f75-8f7a-9616301e5143 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(get-token)" \
  -d '"Hi, I'\''m Hiram, who are you?"'

Example output:

Hello Hiram! I'm an AI assistant here to help you with any questions or information you might need. How can I assist you today?
curl -NsSfX POST http://localhost:9090/chat/2cdbd4b0-b48d-4f75-8f7a-9616301e5143 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(get-token)" \
  -d '"Who am I?"'

Example output:

You are Hiram.

If you browse to the demo agent app at http://localhost:8080/, you will see that a conversation has been created with the ID 2cdbd4b0-b48d-4f75-8f7a-9616301e5143. But it won’t show any messages. That’s because we are not yet storing what we call the history of the conversation. The only thing being stored is the agent memory, and that’s not typically what you want to display to a user in a UI.

Next Steps

Continue to Conversation History to learn how to:

  • Record conversation history for frontend display
  • Expose conversation APIs (messages, listing)
  • Build a complete chat UI experience

Or jump ahead to: