diff --git a/pom.xml b/pom.xml
index 3f673aa26e0..f7fd8208403 100644
--- a/pom.xml
+++ b/pom.xml
@@ -39,6 +39,7 @@
spring-ai-test
spring-ai-vector-store
spring-ai-rag
+ spring-ai-skill
advisors/spring-ai-advisors-vector-store
memory/repository/spring-ai-model-chat-memory-repository-cassandra
diff --git a/spring-ai-skill/pom.xml b/spring-ai-skill/pom.xml
new file mode 100644
index 00000000000..58ebb271eb6
--- /dev/null
+++ b/spring-ai-skill/pom.xml
@@ -0,0 +1,59 @@
+
+
+
+
+ 4.0.0
+
+ org.springframework.ai
+ spring-ai-parent
+ 2.0.0-SNAPSHOT
+
+ spring-ai-skill
+ jar
+ Spring AI Skill
+ Skill management extension for Spring AI applications
+ https://github.com/spring-projects/spring-ai
+
+
+ https://github.com/spring-projects/spring-ai
+ git://github.com/spring-projects/spring-ai.git
+ git@github.com:spring-projects/spring-ai.git
+
+
+
+ 17
+ 17
+
+
+
+
+ org.springframework.ai
+ spring-ai-client-chat
+ ${project.parent.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/SkillProxy.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/SkillProxy.java
new file mode 100644
index 00000000000..b4dc27d650e
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/SkillProxy.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.adapter;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillMetadata;
+import org.springframework.ai.skill.core.SkillRegistrar;
+import org.springframework.ai.skill.exception.SkillInvocationException;
+import org.springframework.ai.tool.ToolCallback;
+
+/**
+ * Proxy-based Skill implementation wrapping user POJOs annotated with {@code @Skill}.
+ *
+ *
+ * This class acts as a dynamic proxy that delegates method calls to the underlying
+ * annotated POJO instance using reflection. It supports capability extension through JDK
+ * dynamic proxies.
+ *
+ *
+ * INTERNAL USE ONLY: Framework internal class subject to change.
+ *
+ * @author LinPeng Zhang
+ * @see Skill
+ * @see SkillRegistrar
+ * @since 1.1.3
+ */
+public final class SkillProxy implements Skill {
+
+ private final SkillMetadata metadata;
+
+ private final Object delegate;
+
+ private final Map extensionMethods;
+
+ private final Map, Object> capabilityProxies = new ConcurrentHashMap<>();
+
+ /**
+ * Constructor.
+ * @param metadata skill metadata
+ * @param delegate user POJO instance
+ * @param extensionMethods extension method mappings
+ */
+ public SkillProxy(SkillMetadata metadata, Object delegate, Map extensionMethods) {
+ this.metadata = Objects.requireNonNull(metadata, "metadata cannot be null");
+ this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null");
+ this.extensionMethods = Objects.requireNonNull(extensionMethods, "extensionMethods cannot be null");
+ }
+
+ @Override
+ public SkillMetadata getMetadata() {
+ return this.metadata;
+ }
+
+ /**
+ * Gets skill content.
+ * @return skill content
+ * @throws UnsupportedOperationException if no @SkillContent method found
+ * @throws SkillInvocationException if invocation fails
+ */
+ @Override
+ public String getContent() {
+ Method contentMethod = this.extensionMethods.get("content");
+ if (contentMethod == null) {
+ throw new UnsupportedOperationException(
+ "Skill '" + this.getName() + "' does not have @SkillContent annotated method");
+ }
+
+ try {
+ contentMethod.setAccessible(true);
+ Object result = contentMethod.invoke(this.delegate);
+
+ // Validate return type
+ if (result == null) {
+ throw new SkillInvocationException(this.getName(), contentMethod.getName(),
+ "@SkillContent method '" + contentMethod.getName() + "' returned null. "
+ + "The method must return a non-null String value.",
+ null);
+ }
+
+ if (!(result instanceof String)) {
+ throw new SkillInvocationException(this.getName(), contentMethod.getName(),
+ "@SkillContent method '" + contentMethod.getName() + "' must return String, " + "but returned "
+ + result.getClass().getName() + ". " + "Ensure the method signature is: public String "
+ + contentMethod.getName() + "()",
+ null);
+ }
+
+ return (String) result;
+ }
+ catch (SkillInvocationException e) {
+ throw e; // Re-throw our custom exception
+ }
+ catch (Exception e) {
+ throw new SkillInvocationException(this.getName(), contentMethod.getName(),
+ "Failed to invoke @SkillContent method '" + contentMethod.getName() + "' on skill '"
+ + this.getName() + "'. " + "Ensure the method is accessible and does not throw exceptions.",
+ e);
+ }
+ }
+
+ /**
+ * Gets tool callbacks.
+ * @return tool callbacks (empty if no @SkillTools method)
+ * @throws SkillInvocationException if invocation fails
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public List getTools() {
+ Method toolsMethod = this.extensionMethods.get("tools");
+ if (toolsMethod == null) {
+ return Collections.emptyList(); // No @SkillTools method, return empty list
+ }
+
+ try {
+ toolsMethod.setAccessible(true);
+ Object result = toolsMethod.invoke(this.delegate);
+
+ // Validate return type
+ if (result == null) {
+ throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
+ "@SkillTools method '" + toolsMethod.getName() + "' returned null. "
+ + "The method must return a non-null List.",
+ null);
+ }
+
+ if (!(result instanceof List> list)) {
+ throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
+ "@SkillTools method '" + toolsMethod.getName() + "' must return List, "
+ + "but returned " + result.getClass().getName() + ". "
+ + "Ensure the method signature is: public List " + toolsMethod.getName()
+ + "()",
+ null);
+ }
+
+ // Check list contents ( the best effort - generics are erased at runtime)
+ if (!list.isEmpty()) {
+ Object firstElement = list.get(0);
+ if (firstElement != null && !(firstElement instanceof ToolCallback)) {
+ throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
+ "@SkillTools method '" + toolsMethod.getName()
+ + "' returned a list containing invalid elements. "
+ + "Expected List, but found element of type "
+ + firstElement.getClass().getName() + ". "
+ + "Ensure all list elements are instances of ToolCallback.",
+ null);
+ }
+ }
+
+ return (List) result;
+ }
+ catch (SkillInvocationException e) {
+ throw e; // Re-throw our custom exception
+ }
+ catch (Exception e) {
+ throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
+ "Failed to invoke @SkillTools method '" + toolsMethod.getName() + "' on skill '" + this.getName()
+ + "'. " + "Ensure the method is accessible and does not throw exceptions.",
+ e);
+ }
+ }
+
+ @Override
+ public boolean supports(Class capabilityType) {
+ if (!capabilityType.isInterface()) {
+ return false;
+ }
+
+ String expectedKey = this.getCapabilityKey(capabilityType);
+ return expectedKey != null && this.extensionMethods.containsKey(expectedKey);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T as(Class capabilityType) {
+ if (!capabilityType.isInterface()) {
+ throw new IllegalArgumentException("Capability must be an interface: " + capabilityType.getName());
+ }
+
+ if (!this.supports(capabilityType)) {
+ throw new UnsupportedOperationException(
+ "Skill '" + this.getName() + "' does not support capability: " + capabilityType.getName());
+ }
+
+ return (T) this.capabilityProxies.computeIfAbsent(capabilityType, this::createCapabilityProxy);
+ }
+
+ /**
+ * Creates dynamic proxy for capability interface.
+ * @param capability capability interface
+ * @return proxy instance
+ */
+ private Object createCapabilityProxy(Class> capability) {
+ return Proxy.newProxyInstance(capability.getClassLoader(), new Class>[] { capability },
+ (proxy, method, args) -> {
+ String key = this.methodToExtensionKey(method);
+ Method extensionMethod = this.extensionMethods.get(key);
+
+ if (extensionMethod == null) {
+ throw new UnsupportedOperationException("No implementation found for method '"
+ + method.getName() + "' in capability interface '" + capability.getName() + "' "
+ + "for skill '" + this.getName() + "'. " + "Expected an annotated method with key '"
+ + key + "'.");
+ }
+
+ try {
+ extensionMethod.setAccessible(true);
+ return extensionMethod.invoke(this.delegate, args);
+ }
+ catch (Exception e) {
+ throw new SkillInvocationException(this.getName(), extensionMethod.getName(),
+ "Failed to invoke capability method '" + method.getName() + "' (mapped to '"
+ + extensionMethod.getName() + "') " + "for capability '" + capability.getName()
+ + "' " + "on skill '" + this.getName() + "'.",
+ e);
+ }
+ });
+ }
+
+ private @Nullable String getCapabilityKey(Class> capability) {
+ Method[] methods = capability.getDeclaredMethods();
+ if (methods.length == 0) {
+ return null;
+ }
+ return this.methodToExtensionKey(methods[0]);
+ }
+
+ private String methodToExtensionKey(Method method) {
+ String methodName = method.getName();
+ if (methodName.startsWith("get") && methodName.length() > 3) {
+ return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
+ }
+ return methodName;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/package-info.java
new file mode 100644
index 00000000000..a36817f2d26
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Adapter classes for skill proxy and dynamic invocation.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.adapter;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/Skill.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/Skill.java
new file mode 100644
index 00000000000..f00af2d9375
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/Skill.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Class-level annotation for marking a class as a Skill.
+ *
+ *
+ * Supports annotation mode (POJO) and interface mode (Skill implementation).
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface Skill {
+
+ /**
+ * Skill name (required).
+ * @return skill name
+ */
+ String name();
+
+ /**
+ * Skill description (required).
+ * @return skill description
+ */
+ String description();
+
+ /**
+ * Source identifier (optional, default "custom").
+ * @return source identifier
+ */
+ String source() default "custom";
+
+ /**
+ * Extension properties (optional).
+ *
+ *
+ * Format: key=value
+ * @return extension properties array
+ */
+ String[] extensions() default {};
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillContent.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillContent.java
new file mode 100644
index 00000000000..473e852c5ea
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillContent.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks method returning skill markdown content.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@SkillExtension(key = "content", returnType = String.class, required = true)
+public @interface SkillContent {
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillExtension.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillExtension.java
new file mode 100644
index 00000000000..e285898c4c4
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillExtension.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Meta-annotation for custom skill extension methods.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+@Target(ElementType.ANNOTATION_TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface SkillExtension {
+
+ /**
+ * Extension key.
+ * @return key value
+ */
+ String key();
+
+ /**
+ * Expected return type.
+ * @return return type class
+ */
+ Class> returnType() default Object.class;
+
+ /**
+ * Whether extension is required.
+ * @return true if required
+ */
+ boolean required() default false;
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillInit.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillInit.java
new file mode 100644
index 00000000000..dc6d1efe192
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillInit.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks skill initialization factory method.
+ *
+ * @author LinPeng Zhang
+ * @see Skill
+ * @see org.springframework.ai.skill.registration.ClassBasedSkillRegistrar
+ * @since 1.1.3
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface SkillInit {
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillTools.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillTools.java
new file mode 100644
index 00000000000..b7c1ffe0ff3
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillTools.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.List;
+
+/**
+ * Marks method returning skill tool list.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@SkillExtension(key = "tools", returnType = List.class, required = false)
+public @interface SkillTools {
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/package-info.java
new file mode 100644
index 00000000000..2f9ba07dd97
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Annotations for declarative skill definition and configuration.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.annotation;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/ReferencesLoader.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/ReferencesLoader.java
new file mode 100644
index 00000000000..2e6b0aa3bde
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/ReferencesLoader.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.capability;
+
+import java.util.Map;
+
+/**
+ * Extension capability interface for providing reference resources.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public interface ReferencesLoader {
+
+ /**
+ * Gets reference resources map.
+ * @return reference resources (never null, may be empty)
+ */
+ Map getReferences();
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/SkillReferences.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/SkillReferences.java
new file mode 100644
index 00000000000..e35d1ab1450
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/SkillReferences.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.capability;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.Map;
+
+import org.springframework.ai.skill.annotation.SkillExtension;
+
+/**
+ * Marks method returning skill reference resources as Map.
+ *
+ * @author LinPeng Zhang
+ * @see org.springframework.ai.skill.capability.ReferencesLoader
+ * @since 1.1.3
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@SkillExtension(key = "references", returnType = Map.class, required = false)
+public @interface SkillReferences {
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/package-info.java
new file mode 100644
index 00000000000..d4ccae42f1c
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Capability interfaces for skill references and content loading.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.capability;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/LoadStrategy.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/LoadStrategy.java
new file mode 100644
index 00000000000..24cbcb23b25
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/LoadStrategy.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.common;
+
+/**
+ * Skill loading strategy.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public enum LoadStrategy {
+
+ /**
+ * Lazy loading (default) - instance created on first use.
+ */
+ LAZY,
+
+ /**
+ * Eager loading - instance created immediately during registration.
+ */
+ EAGER
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/package-info.java
new file mode 100644
index 00000000000..a26733fb52f
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Common types and enumerations shared across skill components.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.common;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/DefaultSkillKit.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/DefaultSkillKit.java
new file mode 100644
index 00000000000..2bc0fc2cf9e
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/DefaultSkillKit.java
@@ -0,0 +1,471 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.skill.exception.SkillRegistrationException;
+import org.springframework.ai.skill.exception.SkillValidationException;
+import org.springframework.ai.skill.registration.ClassBasedSkillRegistrar;
+import org.springframework.ai.skill.registration.InstanceBasedSkillRegistrar;
+import org.springframework.ai.skill.support.DefaultSkillIdGenerator;
+import org.springframework.ai.skill.tool.SimpleSkillLoaderTools;
+import org.springframework.ai.support.ToolCallbacks;
+import org.springframework.ai.tool.ToolCallback;
+
+/**
+ * Default implementation of SkillKit coordinating SkillBox and SkillPoolManager.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class DefaultSkillKit implements SkillKit {
+
+ private final SkillBox skillBox;
+
+ private final SkillPoolManager poolManager;
+
+ private final SkillIdGenerator idGenerator;
+
+ private final SkillRegistrar> classRegistrar;
+
+ private final SkillRegistrar instanceRegistrar;
+
+ private final List skillLoaderTools;
+
+ private DefaultSkillKit(Builder builder) {
+ this.skillBox = Objects.requireNonNull(builder.skillBox, "skillBox cannot be null");
+ this.poolManager = Objects.requireNonNull(builder.poolManager, "poolManager cannot be null");
+ this.idGenerator = (builder.idGenerator != null) ? builder.idGenerator : new DefaultSkillIdGenerator();
+ this.classRegistrar = (builder.classRegistrar != null) ? builder.classRegistrar
+ : ClassBasedSkillRegistrar.builder().idGenerator(this.idGenerator).build();
+ this.instanceRegistrar = (builder.instanceRegistrar != null) ? builder.instanceRegistrar
+ : InstanceBasedSkillRegistrar.builder().idGenerator(this.idGenerator).build();
+ this.skillLoaderTools = (builder.tools != null) ? builder.tools
+ : List.of(ToolCallbacks.from(SimpleSkillLoaderTools.builder().skillKit(this).build()));
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ public void register(SkillMetadata metadata, Supplier loader) {
+ this.validateRegistrationParameters(metadata, loader);
+ String skillId = this.buildSkillId(metadata);
+
+ if (this.poolManager.hasDefinition(skillId)) {
+ throw new SkillRegistrationException(skillId, "Skill with ID '" + skillId + "' already registered. "
+ + "Each combination of (name, source) must be unique.");
+ }
+
+ SkillDefinition definition = SkillDefinition.builder()
+ .skillId(skillId)
+ .source(metadata.getSource())
+ .loader(loader)
+ .metadata(metadata)
+ .build();
+
+ this.poolManager.registerDefinition(definition);
+ this.skillBox.addSkill(metadata.getName(), metadata);
+ }
+
+ // ==================== Skill Registration ====================
+
+ @Override
+ public void register(Object instance) {
+ SkillDefinition definition = this.instanceRegistrar.register(this.poolManager, instance);
+ this.skillBox.addSkill(definition.getMetadata().getName(), definition.getMetadata());
+ }
+
+ @Override
+ public void register(Class> skillClass) {
+ SkillDefinition definition = this.classRegistrar.register(this.poolManager, skillClass);
+ this.skillBox.addSkill(definition.getMetadata().getName(), definition.getMetadata());
+ }
+
+ protected String buildSkillId(SkillMetadata metadata) {
+ return this.idGenerator.generateId(metadata);
+ }
+
+ private void validateRegistrationParameters(SkillMetadata metadata, Supplier loader) {
+ if (metadata == null) {
+ throw new SkillValidationException(null, "metadata cannot be null");
+ }
+
+ String name = metadata.getName();
+ if (name == null || name.trim().isEmpty()) {
+ throw new SkillValidationException(null, "metadata.name cannot be null or empty. "
+ + "Example: SkillMetadata.builder(\"calculator\", \"Calculator skill\", \"spring\").build()");
+ }
+
+ String description = metadata.getDescription();
+ if (description == null || description.trim().isEmpty()) {
+ throw new SkillValidationException(null, "metadata.description cannot be null or empty. "
+ + "Example: SkillMetadata.builder(\"calculator\", \"A skill for calculations\", \"spring\").build()");
+ }
+
+ String source = metadata.getSource();
+ if (source == null || source.trim().isEmpty()) {
+ throw new SkillValidationException(null,
+ "metadata.source cannot be null or empty. "
+ + "Common sources: \"spring\", \"official\", \"filesystem\", \"database\". "
+ + "Example: SkillMetadata.builder(\"calculator\", \"desc\", \"spring\").build()");
+ }
+
+ if (loader == null) {
+ throw new SkillValidationException(null,
+ "loader cannot be null. " + "Example: skillKit.registerSkill(metadata, () -> new MySkill())");
+ }
+ }
+
+ /**
+ * Gets skill instance by skillId.
+ * @param skillId unique skill ID (format: {name}_{source})
+ * @return skill instance
+ * @throws org.springframework.ai.skill.exception.SkillNotFoundException if skill not
+ * found
+ * @throws org.springframework.ai.skill.exception.SkillLoadException if loading fails
+ */
+ @Override
+ public Skill getSkill(String skillId) {
+ return this.poolManager.load(skillId);
+ }
+
+ // ==================== Skill Access ====================
+
+ /**
+ * Gets skill instance by name from SkillBox.
+ * @param name skill name
+ * @return skill instance, null if not found
+ */
+ @Override
+ public @Nullable Skill getSkillByName(String name) {
+ SkillMetadata metadata = this.skillBox.getMetadata(name);
+ if (metadata == null) {
+ return null;
+ }
+ String skillId = this.buildSkillId(metadata);
+ return this.poolManager.load(skillId);
+ }
+
+ /**
+ * Gets skill metadata by name from SkillBox without triggering skill loading.
+ * @param name skill name
+ * @return skill metadata, null if not found
+ */
+ @Override
+ public @Nullable SkillMetadata getMetadata(String name) {
+ return this.skillBox.getMetadata(name);
+ }
+
+ /**
+ * Checks if skill exists in SkillBox.
+ * @param name skill name
+ * @return true if exists
+ */
+ @Override
+ public boolean exists(String name) {
+ return this.skillBox.exists(name);
+ }
+
+ /**
+ * Adds skill metadata to SkillBox from PoolManager.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found in PoolManager or already
+ * exists in SkillBox
+ */
+ public void addSkillToBox(String name) {
+ SkillDefinition definition = this.findDefinitionByName(name);
+ if (definition == null) {
+ throw new IllegalArgumentException("Skill not found in PoolManager: " + name + ". "
+ + "Please register the skill first using registerSkill().");
+ }
+
+ this.skillBox.addSkill(name, definition.getMetadata());
+ }
+
+ // ==================== SkillBox Management ====================
+
+ /**
+ * Finds skill definition by name, checking SkillBox sources in priority order.
+ * @param name skill name
+ * @return skill definition, null if not found
+ */
+ protected @Nullable SkillDefinition findDefinitionByName(String name) {
+ SkillMetadata metadata = this.skillBox.getMetadata(name);
+ if (metadata != null) {
+ String metadataSource = metadata.getSource();
+ if (!this.skillBox.getSources().contains(metadataSource)) {
+ return null;
+ }
+
+ String skillId = this.buildSkillId(metadata);
+ return this.poolManager.getDefinition(skillId);
+ }
+
+ List allDefinitions = this.poolManager.getDefinitions();
+
+ for (String source : this.skillBox.getSources()) {
+ for (SkillDefinition definition : allDefinitions) {
+ SkillMetadata defMetadata = definition.getMetadata();
+ if (name.equals(defMetadata.getName()) && source.equals(defMetadata.getSource())) {
+ String expectedSkillId = this.buildSkillId(defMetadata);
+ if (expectedSkillId.equals(definition.getSkillId())) {
+ return definition;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Activates skill by name.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found in SkillBox
+ */
+ @Override
+ public void activateSkill(String name) {
+ if (!this.skillBox.exists(name)) {
+ throw new IllegalArgumentException("Skill not found in SkillBox: " + name + ". "
+ + "Please register the skill first using registerSkill().");
+ }
+
+ this.skillBox.activateSkill(name);
+ }
+
+ // ==================== Skill Activation ====================
+
+ /**
+ * Deactivates skill by name.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found in SkillBox
+ */
+ @Override
+ public void deactivateSkill(String name) {
+ if (!this.skillBox.exists(name)) {
+ throw new IllegalArgumentException("Skill not found in SkillBox: " + name);
+ }
+
+ this.skillBox.deactivateSkill(name);
+ }
+
+ /**
+ * Deactivates all skills.
+ */
+ @Override
+ public void deactivateAllSkills() {
+ this.skillBox.deactivateAllSkills();
+ }
+
+ /**
+ * Checks if skill is activated.
+ * @param name skill name
+ * @return true if activated
+ */
+ @Override
+ public boolean isActivated(String name) {
+ return this.skillBox.isActivated(name);
+ }
+
+ /**
+ * Gets all tool callbacks from activated skills.
+ * @return list of tool callbacks from activated skills
+ * @throws IllegalStateException if activated skill not found in PoolManager
+ */
+ @Override
+ public List getAllActiveTools() {
+ List allTools = new ArrayList<>();
+
+ Set activatedSkillNames = this.skillBox.getActivatedSkillNames();
+
+ for (String name : activatedSkillNames) {
+ SkillDefinition definition = this.findDefinitionByName(name);
+ if (definition == null) {
+ throw new IllegalStateException(
+ "Skill '" + name + "' is activated in SkillBox but not found in PoolManager. "
+ + "This indicates a data inconsistency. "
+ + "Please ensure the skill is properly registered in PoolManager.");
+ }
+
+ String skillId = definition.getSkillId();
+ Skill skill = this.poolManager.load(skillId);
+
+ List tools = skill.getTools();
+ if (tools != null && !tools.isEmpty()) {
+ allTools.addAll(tools);
+ }
+ }
+
+ return allTools;
+ }
+
+ /**
+ * Gets system prompt describing all skills in SkillBox.
+ * @return system prompt string, empty if no skills
+ */
+ @Override
+ public String getSkillSystemPrompt() {
+ Map allMetadata = this.skillBox.getAllMetadata();
+
+ if (allMetadata.isEmpty()) {
+ return "";
+ }
+
+ StringBuilder prompt = new StringBuilder();
+ prompt.append("You have access to the following skills:\n\n");
+
+ int index = 1;
+ for (SkillMetadata metadata : allMetadata.values()) {
+ prompt.append(String.format("%d. %s: %s\n", index++, metadata.getName(), metadata.getDescription()));
+ }
+
+ prompt.append("\n");
+ prompt.append("**How to Use Skills:**\n\n");
+ prompt.append(
+ "1. **loadSkillContent(skillName)**: Load the full content of a skill to understand what it does and what tools it provides.\n");
+ prompt.append(" - Use this when you need to know a skill's capabilities\n");
+ prompt.append(" - The content will describe available tools and how to use them\n\n");
+ prompt.append(
+ "2. **loadSkillReference(skillName, referenceKey)**: Load a specific reference material from a skill.\n");
+ prompt.append(" - ONLY use this when a skill's content EXPLICITLY mentions it has reference materials\n");
+ prompt.append(" - You must use the exact reference key mentioned in the skill's content\n");
+ prompt.append(" - For regular skill operations, use the skill's own tools instead\n");
+
+ return prompt.toString();
+ }
+
+ /**
+ * Gets skill-related tool callbacks for progressive skill loading.
+ * @return list of skill tool callbacks
+ */
+ @Override
+ public List getSkillLoaderTools() {
+ return this.skillLoaderTools;
+ }
+
+ // ==================== Skill Tools ====================
+
+ /**
+ * Builder for DefaultSkillKit.
+ */
+ public static class Builder {
+
+ private @Nullable SkillBox skillBox;
+
+ private @Nullable SkillPoolManager poolManager;
+
+ private @Nullable List tools;
+
+ private @Nullable SkillIdGenerator idGenerator;
+
+ private @Nullable SkillRegistrar> classRegistrar;
+
+ private @Nullable SkillRegistrar instanceRegistrar;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the skill box.
+ * @param skillBox skill box instance
+ * @return this builder
+ */
+ public Builder skillBox(SkillBox skillBox) {
+ this.skillBox = skillBox;
+ return this;
+ }
+
+ /**
+ * Sets the pool manager.
+ * @param poolManager pool manager instance
+ * @return this builder
+ */
+ public Builder poolManager(SkillPoolManager poolManager) {
+ this.poolManager = poolManager;
+ return this;
+ }
+
+ /**
+ * Sets custom tools.
+ * @param tools tool callbacks list
+ * @return this builder
+ */
+ public Builder tools(List tools) {
+ this.tools = tools;
+ return this;
+ }
+
+ /**
+ * Sets custom ID generator.
+ * @param idGenerator ID generator instance
+ * @return this builder
+ */
+ public Builder idGenerator(SkillIdGenerator idGenerator) {
+ this.idGenerator = idGenerator;
+ return this;
+ }
+
+ /**
+ * Sets custom class-based registrar.
+ * @param classRegistrar class registrar instance
+ * @return this builder
+ */
+ public Builder classRegistrar(SkillRegistrar> classRegistrar) {
+ this.classRegistrar = classRegistrar;
+ return this;
+ }
+
+ /**
+ * Sets custom instance-based registrar.
+ * @param instanceRegistrar instance registrar instance
+ * @return this builder
+ */
+ public Builder instanceRegistrar(SkillRegistrar instanceRegistrar) {
+ this.instanceRegistrar = instanceRegistrar;
+ return this;
+ }
+
+ /**
+ * Builds the DefaultSkillKit instance.
+ * @return new DefaultSkillKit instance
+ */
+ public DefaultSkillKit build() {
+ if (this.skillBox == null) {
+ throw new IllegalArgumentException("skillBox cannot be null");
+ }
+ if (this.poolManager == null) {
+ throw new IllegalArgumentException("poolManager cannot be null");
+ }
+ return new DefaultSkillKit(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/Skill.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/Skill.java
new file mode 100644
index 00000000000..1c46a6e7376
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/Skill.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.springframework.ai.tool.ToolCallback;
+
+/**
+ * Core Skill interface.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public interface Skill {
+
+ /**
+ * Gets skill metadata.
+ * @return skill metadata
+ */
+ SkillMetadata getMetadata();
+
+ /**
+ * Gets skill name.
+ * @return skill name
+ */
+ default String getName() {
+ return this.getMetadata().getName();
+ }
+
+ /**
+ * Gets skill description.
+ * @return skill description
+ */
+ default String getDescription() {
+ return this.getMetadata().getDescription();
+ }
+
+ /**
+ * Gets skill content in Markdown format.
+ * @return skill content
+ */
+ String getContent();
+
+ /**
+ * Gets tool callbacks provided by this skill.
+ * @return tool callback list (never null, defaults to empty)
+ */
+ default List getTools() {
+ return Collections.emptyList();
+ }
+
+ /**
+ * Checks if this skill supports the specified capability interface.
+ * @param capabilityType capability interface class
+ * @param capability type
+ * @return true if supported
+ */
+ default boolean supports(Class capabilityType) {
+ return capabilityType.isInstance(this);
+ }
+
+ /**
+ * Converts this skill to the specified capability interface.
+ * @param capabilityType capability interface class
+ * @param capability type
+ * @return capability instance
+ * @throws ClassCastException if capability not supported
+ */
+ default T as(Class capabilityType) {
+ if (!this.supports(capabilityType)) {
+ throw new ClassCastException(
+ "Skill '" + this.getName() + "' does not support capability: " + capabilityType.getName());
+ }
+ return capabilityType.cast(this);
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillBox.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillBox.java
new file mode 100644
index 00000000000..eb2147ac11e
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillBox.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.List;
+import java.util.Map;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Tenant-level skill metadata container.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public interface SkillBox {
+
+ /**
+ * Adds skill metadata.
+ * @param name skill name
+ * @param metadata skill metadata
+ * @throws IllegalArgumentException if name already exists
+ */
+ void addSkill(String name, SkillMetadata metadata);
+
+ /**
+ * Gets skill metadata.
+ * @param name skill name
+ * @return skill metadata or null if not found
+ */
+ @Nullable SkillMetadata getMetadata(String name);
+
+ /**
+ * Gets all skill metadata.
+ * @return unmodifiable map of name to metadata
+ */
+ Map getAllMetadata();
+
+ /**
+ * Checks if skill exists.
+ * @param name skill name
+ * @return true if exists
+ */
+ boolean exists(String name);
+
+ /**
+ * Gets skill count.
+ * @return skill count
+ */
+ int getSkillCount();
+
+ /**
+ * Activates skill.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found
+ */
+ void activateSkill(String name);
+
+ /**
+ * Deactivates skill.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found
+ */
+ void deactivateSkill(String name);
+
+ /**
+ * Deactivates all skills.
+ */
+ void deactivateAllSkills();
+
+ /**
+ * Checks if skill is activated.
+ * @param name skill name
+ * @return true if activated
+ */
+ boolean isActivated(String name);
+
+ /**
+ * Gets activated skill names.
+ * @return set of activated skill names (never null)
+ */
+ java.util.Set getActivatedSkillNames();
+
+ /**
+ * Gets supported skill sources in priority order.
+ * @return source list (mutable, ordered by priority)
+ */
+ List getSources();
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillDefinition.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillDefinition.java
new file mode 100644
index 00000000000..d5ced4be896
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillDefinition.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.skill.common.LoadStrategy;
+
+/**
+ * Skill definition containing metadata and loading strategy.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillDefinition {
+
+ private final String skillId;
+
+ private final String source;
+
+ private final Supplier loader;
+
+ private final LoadStrategy loadStrategy;
+
+ private final SkillMetadata metadata;
+
+ private SkillDefinition(Builder builder) {
+ this.skillId = Objects.requireNonNull(builder.skillId, "skillId cannot be null");
+ this.source = Objects.requireNonNull(builder.source, "source cannot be null");
+ this.loader = Objects.requireNonNull(builder.loader, "loader cannot be null");
+ this.loadStrategy = Objects.requireNonNull(builder.loadStrategy, "loadStrategy cannot be null");
+ this.metadata = Objects.requireNonNull(builder.metadata, "metadata cannot be null");
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String getSkillId() {
+ return this.skillId;
+ }
+
+ public String getSource() {
+ return this.source;
+ }
+
+ public Supplier getLoader() {
+ return this.loader;
+ }
+
+ public LoadStrategy getLoadStrategy() {
+ return this.loadStrategy;
+ }
+
+ public SkillMetadata getMetadata() {
+ return this.metadata;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || this.getClass() != o.getClass()) {
+ return false;
+ }
+ SkillDefinition that = (SkillDefinition) o;
+ return Objects.equals(this.skillId, that.skillId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.skillId);
+ }
+
+ @Override
+ public String toString() {
+ return "SkillDefinition{" + "skillId='" + this.skillId + '\'' + ", source='" + this.source + '\''
+ + ", loadStrategy=" + this.loadStrategy + ", metadata=" + this.metadata + '}';
+ }
+
+ /**
+ * Builder for immutable SkillDefinition objects.
+ */
+ public static class Builder {
+
+ private @Nullable String skillId;
+
+ private @Nullable String source;
+
+ private @Nullable Supplier loader;
+
+ private LoadStrategy loadStrategy = LoadStrategy.LAZY;
+
+ private @Nullable SkillMetadata metadata;
+
+ private Builder() {
+ }
+
+ public Builder skillId(String skillId) {
+ this.skillId = skillId;
+ return this;
+ }
+
+ public Builder source(String source) {
+ this.source = source;
+ return this;
+ }
+
+ public Builder loader(Supplier loader) {
+ this.loader = loader;
+ return this;
+ }
+
+ public Builder loadStrategy(LoadStrategy loadStrategy) {
+ this.loadStrategy = loadStrategy;
+ return this;
+ }
+
+ public Builder metadata(SkillMetadata metadata) {
+ this.metadata = metadata;
+ return this;
+ }
+
+ public SkillDefinition build() {
+ if (this.skillId == null) {
+ throw new IllegalArgumentException("skillId cannot be null");
+ }
+ if (this.source == null) {
+ throw new IllegalArgumentException("source cannot be null");
+ }
+ if (this.loader == null) {
+ throw new IllegalArgumentException("loader cannot be null");
+ }
+ if (this.loadStrategy == null) {
+ throw new IllegalArgumentException("loadStrategy cannot be null");
+ }
+ if (this.metadata == null) {
+ throw new IllegalArgumentException("metadata cannot be null");
+ }
+ return new SkillDefinition(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillIdGenerator.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillIdGenerator.java
new file mode 100644
index 00000000000..9084812c0e0
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillIdGenerator.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+/**
+ * Skill ID generator interface.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+@FunctionalInterface
+public interface SkillIdGenerator {
+
+ /**
+ * Generates unique skill ID from metadata.
+ * @param metadata skill metadata
+ * @return generated skill ID
+ * @throws IllegalArgumentException if metadata invalid
+ */
+ String generateId(SkillMetadata metadata);
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillKit.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillKit.java
new file mode 100644
index 00000000000..c4d9a05918a
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillKit.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.tool.ToolCallback;
+
+/**
+ * Unified coordination interface for skill management.
+ *
+ * @author LinPeng Zhang
+ * @see DefaultSkillKit
+ * @since 1.1.3
+ */
+public interface SkillKit {
+
+ // ==================== Skill Registration ====================
+
+ /**
+ * Registers skill with metadata and lazy loader.
+ * @param metadata skill metadata
+ * @param loader skill instance loader
+ * @throws org.springframework.ai.skill.exception.SkillValidationException if
+ * validation fails
+ * @throws org.springframework.ai.skill.exception.SkillRegistrationException if
+ * registration fails
+ */
+ void register(SkillMetadata metadata, Supplier loader);
+
+ /**
+ * Registers skill from instance.
+ * @param instance skill instance with @Skill annotation
+ * @throws IllegalArgumentException if instance class lacks @Skill annotation
+ */
+ void register(Object instance);
+
+ /**
+ * Registers skill from class with lazy loading.
+ * @param skillClass skill class with @Skill annotation
+ * @throws IllegalArgumentException if class lacks @Skill annotation
+ */
+ void register(Class> skillClass);
+
+ // ==================== Skill Access ====================
+
+ /**
+ * Checks if skill exists by name.
+ * @param name skill name
+ * @return true if exists
+ */
+ boolean exists(String name);
+
+ /**
+ * Gets skill instance by ID.
+ * @param skillId skill ID
+ * @return skill instance
+ * @throws org.springframework.ai.skill.exception.SkillNotFoundException if not found
+ * @throws org.springframework.ai.skill.exception.SkillLoadException if loading fails
+ */
+ Skill getSkill(String skillId);
+
+ /**
+ * Gets skill instance by name.
+ * @param name skill name
+ * @return skill instance or null if not found
+ */
+ @Nullable Skill getSkillByName(String name);
+
+ /**
+ * Gets skill metadata by name.
+ * @param name skill name
+ * @return skill metadata or null if not found
+ */
+ @Nullable SkillMetadata getMetadata(String name);
+
+ // ==================== Skill Activation ====================
+
+ /**
+ * Activates skill by name.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found
+ */
+ void activateSkill(String name);
+
+ /**
+ * Deactivates skill by name.
+ * @param name skill name
+ * @throws IllegalArgumentException if skill not found
+ */
+ void deactivateSkill(String name);
+
+ /**
+ * Deactivates all skills.
+ */
+ void deactivateAllSkills();
+
+ /**
+ * Checks if skill is activated.
+ * @param name skill name
+ * @return true if activated
+ */
+ boolean isActivated(String name);
+
+ // ==================== Tools ====================
+
+ /**
+ * Gets framework skill tools for progressive loading.
+ * @return skill tool list
+ */
+ List getSkillLoaderTools();
+
+ /**
+ * Gets all active skill tools.
+ * @return active tool list (never null)
+ * @throws IllegalStateException if data inconsistency detected
+ */
+ List getAllActiveTools();
+
+ /**
+ * Gets system prompt describing available skills.
+ * @return system prompt (never null)
+ */
+ String getSkillSystemPrompt();
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillMetadata.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillMetadata.java
new file mode 100644
index 00000000000..840f6d4a6b3
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillMetadata.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Skill metadata.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillMetadata {
+
+ private final String name;
+
+ private final String description;
+
+ private final String source;
+
+ private final Map extensions;
+
+ public SkillMetadata(String name, String description, String source) {
+ this(name, description, source, new HashMap<>());
+ }
+
+ public SkillMetadata(String name, String description, String source, Map extensions) {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("name is required");
+ }
+ if (description == null || description.isEmpty()) {
+ throw new IllegalArgumentException("description is required");
+ }
+ if (source == null || source.isEmpty()) {
+ throw new IllegalArgumentException("source is required");
+ }
+
+ this.name = name;
+ this.description = description;
+ this.source = source;
+ this.extensions = new HashMap<>(extensions);
+ }
+
+ // Builder
+ public static Builder builder(String name, String description, String source) {
+ return new Builder(name, description, source);
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ public String getDescription() {
+ return this.description;
+ }
+
+ public String getSource() {
+ return this.source;
+ }
+
+ public Map getExtensions() {
+ return Collections.unmodifiableMap(this.extensions);
+ }
+
+ public @Nullable Object getExtension(String key) {
+ return this.extensions.get(key);
+ }
+
+ public @Nullable T getExtension(String key, Class type) {
+ Object value = this.extensions.get(key);
+ if (value == null) {
+ return null;
+ }
+ if (!type.isInstance(value)) {
+ throw new ClassCastException("Extension '" + key + "' is not of type " + type.getName());
+ }
+ return type.cast(value);
+ }
+
+ public boolean hasExtension(String key) {
+ return this.extensions.containsKey(key);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || this.getClass() != o.getClass()) {
+ return false;
+ }
+ SkillMetadata that = (SkillMetadata) o;
+ return Objects.equals(this.name, that.name) && Objects.equals(this.description, that.description)
+ && Objects.equals(this.source, that.source) && Objects.equals(this.extensions, that.extensions);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.name, this.description, this.source, this.extensions);
+ }
+
+ @Override
+ public String toString() {
+ return "SkillMetadata{" + "name='" + this.name + '\'' + ", description='" + this.description + '\''
+ + ", source='" + this.source + '\'' + ", extensions=" + this.extensions + '}';
+ }
+
+ public static class Builder {
+
+ private final String name;
+
+ private final String description;
+
+ private final String source;
+
+ private final Map extensions = new HashMap<>();
+
+ private Builder(String name, String description, String source) {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("name is required");
+ }
+ if (description == null || description.isEmpty()) {
+ throw new IllegalArgumentException("description is required");
+ }
+ if (source == null || source.isEmpty()) {
+ throw new IllegalArgumentException("source is required");
+ }
+ this.name = name;
+ this.description = description;
+ this.source = source;
+ }
+
+ public Builder extension(String key, Object value) {
+ this.extensions.put(key, value);
+ return this;
+ }
+
+ public Builder extensions(Map extensions) {
+ this.extensions.putAll(extensions);
+ return this;
+ }
+
+ public SkillMetadata build() {
+ return new SkillMetadata(this.name, this.description, this.source, this.extensions);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillPoolManager.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillPoolManager.java
new file mode 100644
index 00000000000..b90bf8e5b62
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillPoolManager.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+import java.util.List;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.skill.exception.SkillLoadException;
+import org.springframework.ai.skill.exception.SkillNotFoundException;
+
+/**
+ * Skill pool manager for definition storage and instance lifecycle management.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public interface SkillPoolManager {
+
+ /**
+ * Registers skill definition.
+ * @param definition skill definition
+ * @throws IllegalArgumentException if skillId already exists
+ */
+ void registerDefinition(SkillDefinition definition);
+
+ /**
+ * Unregisters skill completely (definition and instance).
+ * @param skillId skill ID
+ */
+ void unregister(String skillId);
+
+ /**
+ * Gets skill definition.
+ * @param skillId skill ID
+ * @return skill definition or null if not found
+ */
+ @Nullable SkillDefinition getDefinition(String skillId);
+
+ /**
+ * Checks if skill definition exists.
+ * @param skillId skill ID
+ * @return true if exists
+ */
+ boolean hasDefinition(String skillId);
+
+ /**
+ * Loads skill instance (singleton per skillId).
+ * @param skillId skill ID
+ * @return skill instance
+ * @throws SkillNotFoundException if skill not found
+ * @throws SkillLoadException if loading fails
+ */
+ Skill load(String skillId);
+
+ /**
+ * Gets all registered skill definitions.
+ * @return skill definition list (never null)
+ */
+ List getDefinitions();
+
+ /**
+ * Evicts skill instance but retains definition.
+ * @param skillId skill ID
+ */
+ void evict(String skillId);
+
+ /**
+ * Evicts all skill instances but retains definitions.
+ */
+ void evictAll();
+
+ /**
+ * Clears all definitions and instances.
+ */
+ void clear();
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillRegistrar.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillRegistrar.java
new file mode 100644
index 00000000000..55c3f3b6a86
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillRegistrar.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.core;
+
+/**
+ * Skill registrar interface for registering skills from various sources.
+ *
+ * @param source type (Class, Object, String, Path, etc.)
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public interface SkillRegistrar {
+
+ /**
+ * Creates SkillDefinition from source and registers to SkillPoolManager.
+ * @param poolManager skill pool manager
+ * @param source registration source
+ * @return created SkillDefinition
+ * @throws IllegalArgumentException if source invalid
+ * @throws org.springframework.ai.skill.exception.SkillRegistrationException if
+ * registration fails
+ */
+ SkillDefinition register(SkillPoolManager poolManager, T source);
+
+ /**
+ * Checks if registrar supports given source.
+ * @param source source object
+ * @return true if supported
+ */
+ boolean supports(Object source);
+
+ /**
+ * Gets registrar name for logging.
+ * @return registrar name
+ */
+ default String getName() {
+ return this.getClass().getSimpleName();
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/package-info.java
new file mode 100644
index 00000000000..486fbd5942b
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Core interfaces and implementations for skill management and lifecycle.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.core;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillException.java
new file mode 100644
index 00000000000..df31f38fe9e
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillException.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.exception;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Base exception for skill framework.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillException extends RuntimeException {
+
+ public SkillException(String message) {
+ super(message);
+ }
+
+ public SkillException(String message, @Nullable Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillInvocationException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillInvocationException.java
new file mode 100644
index 00000000000..91486827e9d
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillInvocationException.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.exception;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Thrown when skill method invocation fails.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillInvocationException extends SkillException {
+
+ private final String skillId;
+
+ private final String methodName;
+
+ public SkillInvocationException(String skillId, String methodName, String message, @Nullable Throwable cause) {
+ super("Failed to invoke method '" + methodName + "' on skill '" + skillId + "': " + message, cause);
+ this.skillId = skillId;
+ this.methodName = methodName;
+ }
+
+ public String getSkillId() {
+ return this.skillId;
+ }
+
+ public String getMethodName() {
+ return this.methodName;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillLoadException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillLoadException.java
new file mode 100644
index 00000000000..d6b4d3acd53
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillLoadException.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.exception;
+
+/**
+ * Thrown when skill loading fails.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillLoadException extends SkillException {
+
+ private final String skillId;
+
+ public SkillLoadException(String skillId, String message, Throwable cause) {
+ super("Failed to load skill '" + skillId + "': " + message, cause);
+ this.skillId = skillId;
+ }
+
+ public String getSkillId() {
+ return this.skillId;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillNotFoundException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillNotFoundException.java
new file mode 100644
index 00000000000..fe543c6425f
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillNotFoundException.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.exception;
+
+/**
+ * Thrown when requested skill not found.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillNotFoundException extends SkillException {
+
+ private final String skillId;
+
+ public SkillNotFoundException(String skillId) {
+ super("Skill not found: '" + skillId + "'");
+ this.skillId = skillId;
+ }
+
+ public String getSkillId() {
+ return this.skillId;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillRegistrationException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillRegistrationException.java
new file mode 100644
index 00000000000..48ed0df62a0
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillRegistrationException.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.exception;
+
+/**
+ * Thrown when skill registration fails.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillRegistrationException extends SkillException {
+
+ private final String skillId;
+
+ public SkillRegistrationException(String skillId, String message) {
+ super("Failed to register skill '" + skillId + "': " + message);
+ this.skillId = skillId;
+ }
+
+ public SkillRegistrationException(String skillId, String message, Throwable cause) {
+ super("Failed to register skill '" + skillId + "': " + message, cause);
+ this.skillId = skillId;
+ }
+
+ public String getSkillId() {
+ return this.skillId;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillValidationException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillValidationException.java
new file mode 100644
index 00000000000..05663b78657
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillValidationException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.exception;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Thrown when skill validation fails.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SkillValidationException extends SkillException {
+
+ private final @Nullable String skillClass;
+
+ public SkillValidationException(@Nullable String skillClass, String message) {
+ super("Validation failed" + (skillClass != null ? " for skill class '" + skillClass + "'" : "") + ": "
+ + message);
+ this.skillClass = skillClass;
+ }
+
+ public @Nullable String getSkillClass() {
+ return this.skillClass;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/package-info.java
new file mode 100644
index 00000000000..2e05632c485
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Exception hierarchy for skill-related errors and failures.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.exception;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/package-info.java
new file mode 100644
index 00000000000..ebcba7edebd
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/package-info.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The {@code org.springframework.ai.skill} package provides classes and interfaces for
+ * managing and executing skills (modular AI capabilities) in Spring AI applications. It
+ * includes core components for skill registration, lifecycle management, and progressive
+ * loading.
+ *
+ *
+ * Key classes and interfaces:
+ *
+ *
+ * {@link org.springframework.ai.skill.core.SkillKit} - Main entry point for skill
+ * management, coordinating {@link org.springframework.ai.skill.core.SkillBox} and
+ * {@link org.springframework.ai.skill.core.SkillPoolManager}.
+ * {@link org.springframework.ai.skill.core.DefaultSkillKit} - Default implementation
+ * of {@link org.springframework.ai.skill.core.SkillKit}.
+ * {@link org.springframework.ai.skill.core.SkillBox} - Container for managing skill
+ * metadata and activation state.
+ * {@link org.springframework.ai.skill.support.SimpleSkillBox} - Default
+ * implementation of {@link org.springframework.ai.skill.core.SkillBox}.
+ * {@link org.springframework.ai.skill.core.SkillPoolManager} - Manages skill
+ * instances, definitions, and caching strategies.
+ * {@link org.springframework.ai.skill.support.DefaultSkillPoolManager} - Default
+ * implementation of {@link org.springframework.ai.skill.core.SkillPoolManager}.
+ * {@link org.springframework.ai.skill.core.Skill} - Core interface representing an
+ * executable skill with metadata, content, and tools.
+ * {@link org.springframework.ai.skill.core.SkillMetadata} - Immutable metadata
+ * describing a skill's name, description, source, and extensions.
+ * {@link org.springframework.ai.skill.core.SkillDefinition} - Defines how a skill
+ * should be loaded and managed.
+ * {@link org.springframework.ai.skill.core.SkillRegistrar} - SPI for pluggable skill
+ * registration strategies.
+ * {@link org.springframework.ai.skill.registration.ClassBasedSkillRegistrar} -
+ * Registers skills from annotated classes with {@code @SkillInit} factory methods.
+ * {@link org.springframework.ai.skill.registration.InstanceBasedSkillRegistrar} -
+ * Registers skills from pre-created instances annotated with {@code @Skill}.
+ *
+ *
+ *
+ * Spring AI integration classes:
+ *
+ *
+ * {@link org.springframework.ai.skill.spi.SkillAwareAdvisor} - Chat advisor for
+ * injecting skill system prompts and managing skill lifecycle.
+ * {@link org.springframework.ai.skill.spi.SkillAwareToolCallbackResolver} - Resolves
+ * tool callbacks dynamically from active skills.
+ * {@link org.springframework.ai.skill.spi.SkillAwareToolCallingManager} - Manages
+ * tool calling with skill-aware tool resolution.
+ * {@link org.springframework.ai.skill.tool.SimpleSkillLoaderTools} - Provides
+ * {@code loadSkillContent} and {@code loadSkillReference} tools for progressive skill
+ * loading.
+ *
+ *
+ *
+ * Annotation-driven development:
+ *
+ *
+ * {@link org.springframework.ai.skill.annotation.Skill} - Marks a class as a skill
+ * with metadata.
+ * {@link org.springframework.ai.skill.annotation.SkillInit} - Marks a static factory
+ * method for lazy skill instantiation.
+ * {@link org.springframework.ai.skill.annotation.SkillContent} - Marks a method that
+ * provides skill documentation.
+ * {@link org.springframework.ai.skill.annotation.SkillTools} - Marks a method that
+ * provides skill tool callbacks.
+ *
+ *
+ *
+ * Exception hierarchy:
+ *
+ *
+ * {@link org.springframework.ai.skill.exception.SkillException} - Base exception for
+ * all skill-related errors.
+ * {@link org.springframework.ai.skill.exception.SkillNotFoundException} - Thrown when
+ * a requested skill cannot be found.
+ * {@link org.springframework.ai.skill.exception.SkillLoadException} - Thrown when a
+ * skill fails to load.
+ * {@link org.springframework.ai.skill.exception.SkillRegistrationException} - Thrown
+ * when skill registration fails.
+ * {@link org.springframework.ai.skill.exception.SkillValidationException} - Thrown
+ * when skill validation fails.
+ *
+ */
+@NullMarked
+package org.springframework.ai.skill;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/ClassBasedSkillRegistrar.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/ClassBasedSkillRegistrar.java
new file mode 100644
index 00000000000..de30cfc008d
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/ClassBasedSkillRegistrar.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.registration;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.skill.adapter.SkillProxy;
+import org.springframework.ai.skill.annotation.SkillContent;
+import org.springframework.ai.skill.annotation.SkillInit;
+import org.springframework.ai.skill.annotation.SkillTools;
+import org.springframework.ai.skill.capability.SkillReferences;
+import org.springframework.ai.skill.common.LoadStrategy;
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillDefinition;
+import org.springframework.ai.skill.core.SkillIdGenerator;
+import org.springframework.ai.skill.core.SkillMetadata;
+import org.springframework.ai.skill.core.SkillPoolManager;
+import org.springframework.ai.skill.core.SkillRegistrar;
+import org.springframework.ai.skill.exception.SkillRegistrationException;
+import org.springframework.ai.skill.support.DefaultSkillIdGenerator;
+
+/**
+ * Class-based skill registrar for lazy-loaded POJO skills.
+ *
+ *
+ * Requirements: Class must be annotated with @Skill and have a @SkillInit static factory
+ * method.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class ClassBasedSkillRegistrar implements SkillRegistrar> {
+
+ private final SkillIdGenerator idGenerator;
+
+ private ClassBasedSkillRegistrar(Builder builder) {
+ this.idGenerator = (builder.idGenerator != null) ? builder.idGenerator : new DefaultSkillIdGenerator();
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Finds and validates @SkillInit method.
+ * @param skillClass skill class
+ * @return validated @SkillInit method
+ * @throws IllegalArgumentException if method not found or invalid
+ */
+ private static Method findAndValidateSkillInitMethod(Class> skillClass) {
+ Method initMethod = null;
+
+ for (Method method : skillClass.getDeclaredMethods()) {
+ if (method.isAnnotationPresent(SkillInit.class)) {
+ if (initMethod != null) {
+ throw new IllegalArgumentException("Class " + skillClass.getName()
+ + " must have exactly one @SkillInit method, but found multiple");
+ }
+ initMethod = method;
+ }
+ }
+
+ if (initMethod == null) {
+ throw new IllegalArgumentException(
+ "Class " + skillClass.getName() + " must have a @SkillInit annotated method. "
+ + "Example: @SkillInit public static MySkill create() { return new MySkill(); }");
+ }
+
+ Method method = initMethod;
+
+ if (!Modifier.isStatic(method.getModifiers())) {
+ throw new IllegalArgumentException("@SkillInit method '" + method.getName() + "' in class "
+ + skillClass.getName() + " must be static. Example: public static MySkill create() { ... }");
+ }
+
+ Class> returnType = method.getReturnType();
+ if (!returnType.isAssignableFrom(skillClass)) {
+ throw new IllegalArgumentException("@SkillInit method '" + method.getName() + "' in class "
+ + skillClass.getName() + " must return type " + skillClass.getSimpleName()
+ + " (or its superclass), but returns " + returnType.getSimpleName());
+ }
+
+ method.setAccessible(true);
+
+ return method;
+ }
+
+ /**
+ * Builds Skill from instance.
+ * @param instance skill instance
+ * @param metadata skill metadata
+ * @return Skill instance (either direct or wrapped)
+ */
+ protected static Skill buildSkillFromInstance(Object instance, SkillMetadata metadata) {
+ if (instance instanceof Skill) {
+ return (Skill) instance;
+ }
+
+ Map extensionMethods = extractExtensionMethods(instance.getClass());
+ return new SkillProxy(metadata, instance, extensionMethods);
+ }
+
+ /**
+ * Extracts annotated extension methods from class.
+ * @param clazz class to scan
+ * @return extension method map
+ */
+ protected static Map extractExtensionMethods(Class> clazz) {
+ Map extensionMethods = new HashMap<>();
+
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (method.isAnnotationPresent(SkillContent.class)) {
+ extensionMethods.put("content", method);
+ }
+ if (method.isAnnotationPresent(SkillTools.class)) {
+ extensionMethods.put("tools", method);
+ }
+ if (method.isAnnotationPresent(SkillReferences.class)) {
+ extensionMethods.put("references", method);
+ }
+ }
+
+ return extensionMethods;
+ }
+
+ /**
+ * Extracts extension properties from @Skill annotation.
+ * @param skillAnnotation @Skill annotation
+ * @return extension properties map
+ */
+ protected static Map extractExtensions(
+ org.springframework.ai.skill.annotation.Skill skillAnnotation) {
+ Map extensions = new HashMap<>();
+
+ for (String ext : skillAnnotation.extensions()) {
+ String[] parts = ext.split("=", 2);
+ if (parts.length == 2) {
+ extensions.put(parts[0].trim(), parts[1].trim());
+ }
+ }
+
+ return extensions;
+ }
+
+ /**
+ * Checks if class has @SkillInit method.
+ * @param clazz class to check
+ * @return true if at least one @SkillInit method found
+ */
+ private static boolean hasSkillInitMethod(Class> clazz) {
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (method.isAnnotationPresent(SkillInit.class)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Registers skill from class definition.
+ * @param poolManager skill pool manager
+ * @param skillClass skill class annotated with @Skill and @SkillInit
+ * @return created skill definition
+ * @throws IllegalArgumentException if class missing required annotations
+ * @throws SkillRegistrationException if registration fails
+ */
+ @Override
+ public SkillDefinition register(SkillPoolManager poolManager, Class> skillClass) {
+ Objects.requireNonNull(poolManager, "poolManager cannot be null");
+ Objects.requireNonNull(skillClass, "skillClass cannot be null");
+
+ org.springframework.ai.skill.annotation.Skill skillAnnotation = skillClass
+ .getAnnotation(org.springframework.ai.skill.annotation.Skill.class);
+
+ if (skillAnnotation == null) {
+ throw new IllegalArgumentException("Class " + skillClass.getName() + " must be annotated with @Skill");
+ }
+
+ Method initMethod = findAndValidateSkillInitMethod(skillClass);
+
+ String source = skillAnnotation.source();
+
+ SkillMetadata metadata = SkillMetadata.builder(skillAnnotation.name(), skillAnnotation.description(), source)
+ .extensions(extractExtensions(skillAnnotation))
+ .build();
+
+ String skillId = this.idGenerator.generateId(metadata);
+
+ Supplier loader = () -> {
+ try {
+ Object instance = initMethod.invoke(null);
+ return buildSkillFromInstance(instance, metadata);
+ }
+ catch (Exception e) {
+ throw new SkillRegistrationException(metadata.getName(),
+ "Failed to instantiate skill class via @SkillInit method: " + skillClass.getName(), e);
+ }
+ };
+
+ SkillDefinition definition = SkillDefinition.builder()
+ .skillId(skillId)
+ .source(source)
+ .loader(loader)
+ .metadata(metadata)
+ .loadStrategy(LoadStrategy.LAZY)
+ .build();
+
+ poolManager.registerDefinition(definition);
+
+ return definition;
+ }
+
+ // ==================== SkillRegistrar Interface ====================
+
+ /**
+ * Checks if source is supported.
+ * @param source source object to check
+ * @return true if source is a Class with @Skill and @SkillInit
+ */
+ @Override
+ public boolean supports(Object source) {
+ if (!(source instanceof Class> clazz)) {
+ return false;
+ }
+
+ if (!clazz.isAnnotationPresent(org.springframework.ai.skill.annotation.Skill.class)) {
+ return false;
+ }
+
+ return hasSkillInitMethod(clazz);
+ }
+
+ /**
+ * Builder for ClassBasedSkillRegistrar.
+ */
+ public static class Builder {
+
+ private @Nullable SkillIdGenerator idGenerator;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets custom ID generator.
+ * @param idGenerator ID generator instance
+ * @return this builder
+ */
+ public Builder idGenerator(SkillIdGenerator idGenerator) {
+ this.idGenerator = idGenerator;
+ return this;
+ }
+
+ /**
+ * Builds the ClassBasedSkillRegistrar instance.
+ * @return new ClassBasedSkillRegistrar instance
+ */
+ public ClassBasedSkillRegistrar build() {
+ return new ClassBasedSkillRegistrar(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/InstanceBasedSkillRegistrar.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/InstanceBasedSkillRegistrar.java
new file mode 100644
index 00000000000..5aa0cf0c924
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/InstanceBasedSkillRegistrar.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.registration;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.ai.skill.adapter.SkillProxy;
+import org.springframework.ai.skill.annotation.SkillContent;
+import org.springframework.ai.skill.annotation.SkillTools;
+import org.springframework.ai.skill.capability.SkillReferences;
+import org.springframework.ai.skill.common.LoadStrategy;
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillDefinition;
+import org.springframework.ai.skill.core.SkillIdGenerator;
+import org.springframework.ai.skill.core.SkillMetadata;
+import org.springframework.ai.skill.core.SkillPoolManager;
+import org.springframework.ai.skill.core.SkillRegistrar;
+import org.springframework.ai.skill.support.DefaultSkillIdGenerator;
+
+/**
+ * Instance-based skill registrar for pre-created POJO instances.
+ *
+ *
+ * Supports both interface mode (Skill implementation) and annotation mode (@Skill POJO).
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class InstanceBasedSkillRegistrar implements SkillRegistrar {
+
+ private final SkillIdGenerator idGenerator;
+
+ private InstanceBasedSkillRegistrar(Builder builder) {
+ this.idGenerator = (builder.idGenerator != null) ? builder.idGenerator : new DefaultSkillIdGenerator();
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builds Skill from instance.
+ * @param instance skill instance
+ * @param metadata skill metadata
+ * @return Skill instance (either direct or wrapped)
+ */
+ protected static Skill buildSkillFromInstance(Object instance, SkillMetadata metadata) {
+ if (instance instanceof Skill) {
+ return (Skill) instance;
+ }
+
+ Map extensionMethods = extractExtensionMethods(instance.getClass());
+ return new SkillProxy(metadata, instance, extensionMethods);
+ }
+
+ /**
+ * Extracts annotated extension methods from class.
+ * @param clazz class to scan
+ * @return extension method map
+ */
+ protected static Map extractExtensionMethods(Class> clazz) {
+ Map extensionMethods = new HashMap<>();
+
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (method.isAnnotationPresent(SkillContent.class)) {
+ extensionMethods.put("content", method);
+ }
+ if (method.isAnnotationPresent(SkillTools.class)) {
+ extensionMethods.put("tools", method);
+ }
+ if (method.isAnnotationPresent(SkillReferences.class)) {
+ extensionMethods.put("references", method);
+ }
+ }
+
+ return extensionMethods;
+ }
+
+ /**
+ * Extracts extension properties from @Skill annotation.
+ * @param skillAnnotation @Skill annotation
+ * @return extension properties map
+ */
+ protected static Map extractExtensions(
+ org.springframework.ai.skill.annotation.Skill skillAnnotation) {
+ Map extensions = new HashMap<>();
+
+ for (String ext : skillAnnotation.extensions()) {
+ String[] parts = ext.split("=", 2);
+ if (parts.length == 2) {
+ extensions.put(parts[0].trim(), parts[1].trim());
+ }
+ }
+
+ return extensions;
+ }
+
+ /**
+ * Registers skill from instance.
+ * @param poolManager skill pool manager
+ * @param instance skill instance annotated with @Skill
+ * @return created skill definition
+ * @throws IllegalArgumentException if instance class missing @Skill annotation
+ */
+ @Override
+ public SkillDefinition register(SkillPoolManager poolManager, Object instance) {
+ Objects.requireNonNull(poolManager, "poolManager cannot be null");
+ Objects.requireNonNull(instance, "instance cannot be null");
+
+ Class> skillClass = instance.getClass();
+ org.springframework.ai.skill.annotation.Skill skillAnnotation = skillClass
+ .getAnnotation(org.springframework.ai.skill.annotation.Skill.class);
+
+ if (skillAnnotation == null) {
+ throw new IllegalArgumentException(
+ "Instance class " + skillClass.getName() + " must be annotated with @Skill");
+ }
+
+ String source = skillAnnotation.source();
+
+ SkillMetadata metadata = SkillMetadata.builder(skillAnnotation.name(), skillAnnotation.description(), source)
+ .extensions(extractExtensions(skillAnnotation))
+ .build();
+
+ String skillId = this.idGenerator.generateId(metadata);
+
+ Supplier loader = () -> buildSkillFromInstance(instance, metadata);
+
+ SkillDefinition definition = SkillDefinition.builder()
+ .skillId(skillId)
+ .source(source)
+ .loader(loader)
+ .metadata(metadata)
+ .loadStrategy(LoadStrategy.LAZY)
+ .build();
+
+ poolManager.registerDefinition(definition);
+
+ return definition;
+ }
+
+ /**
+ * Checks if source is supported.
+ * @param source source object to check
+ * @return true if source is non-null object with @Skill annotation
+ */
+ @Override
+ public boolean supports(Object source) {
+ return source != null
+ && source.getClass().isAnnotationPresent(org.springframework.ai.skill.annotation.Skill.class);
+ }
+
+ // ==================== SkillRegistrar Interface ====================
+
+ /**
+ * Builder for InstanceBasedSkillRegistrar.
+ */
+ public static class Builder {
+
+ private @Nullable SkillIdGenerator idGenerator;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets custom ID generator.
+ * @param idGenerator ID generator instance
+ * @return this builder
+ */
+ public Builder idGenerator(SkillIdGenerator idGenerator) {
+ this.idGenerator = idGenerator;
+ return this;
+ }
+
+ /**
+ * Builds the InstanceBasedSkillRegistrar instance.
+ * @return new InstanceBasedSkillRegistrar instance
+ */
+ public InstanceBasedSkillRegistrar build() {
+ return new InstanceBasedSkillRegistrar(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/package-info.java
new file mode 100644
index 00000000000..94c2f2e9b28
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Registration strategies for class-based and instance-based skill registration.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.registration;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareAdvisor.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareAdvisor.java
new file mode 100644
index 00000000000..a9980eee9ed
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareAdvisor.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.spi;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+
+import org.springframework.ai.chat.client.ChatClientMessageAggregator;
+import org.springframework.ai.chat.client.ChatClientRequest;
+import org.springframework.ai.chat.client.ChatClientResponse;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
+import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
+import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.skill.core.SkillKit;
+
+/**
+ * Skill Advisor - manages skill lifecycle and system prompt injection.
+ *
+ *
+ * This advisor implements CallAdvisor and StreamAdvisor to:
+ *
+ *
+ * Inject skill system prompt before conversation
+ * Clean up skill state after conversation
+ *
+ *
+ *
+ * Tool Injection:
+ *
+ * Dynamic tool injection is handled by {@code SkillAwareToolCallingManager}
+ * SkillAwareToolCallingManager.resolveToolDefinitions() provides tools to LLM
+ * SkillAwareToolCallbackResolver.resolve() finds tools during execution
+ * This advisor ONLY injects skill system prompt and manages skill lifecycle
+ *
+ *
+ *
+ * Usage with SkillAwareToolCallingManager (recommended):
+ *
+ *
{@code
+ * // 1. Create SkillKit and register skills
+ * SkillBox skillBox = new SimpleSkillBox();
+ * SkillPoolManager poolManager = new DefaultSkillPoolManager();
+ * SkillKit skillKit = new DefaultSkillKit(skillBox, poolManager);
+ * skillKit.registerSkill(metadata, loader);
+ *
+ * // 2. Create SkillAwareToolCallingManager for tool injection
+ * SkillAwareToolCallingManager toolManager =
+ * SkillAwareToolCallingManager.builder()
+ * .skillKit(skillKit)
+ * .build();
+ *
+ * // 3. Create ChatModel with SkillAwareToolCallingManager
+ * OpenAiChatModel chatModel = OpenAiChatModel.builder()
+ * .toolCallingManager(toolManager) // Handles tool injection
+ * .build();
+ *
+ * // 4. Create SkillAwareAdvisor for prompt injection
+ * SkillAwareAdvisor advisor = new SkillAwareAdvisor(skillKit);
+ *
+ * // Build ChatClient with ONLY the advisor (NO tools registered at build time!)
+ * ChatClient client = ChatClient.builder(chatModel)
+ * .defaultSystem("Your base system prompt here")
+ * .defaultAdvisors(advisor)
+ * .build();
+ * }
+ *
+ *
+ * Alternative usage with static helpers (simpler):
+ *
+ *
{@code
+ * String systemPrompt = SkillAwareAdvisor.buildSystemPrompt(basePrompt, skillKit);
+ * ChatClient client = ChatClient.builder(chatModel)
+ * .defaultSystem(systemPrompt)
+ * .build();
+ * // Don't forget to cleanup after each turn:
+ * SkillAwareAdvisor.cleanupSkills(skillKit);
+ * }
+ */
+public class SkillAwareAdvisor implements CallAdvisor, StreamAdvisor {
+
+ private static final Logger logger = LoggerFactory.getLogger(SkillAwareAdvisor.class);
+
+ private final SkillKit skillKit;
+
+ private SkillAwareAdvisor(Builder builder) {
+ this.skillKit = Objects.requireNonNull(builder.skillKit, "skillKit cannot be null");
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Build system prompt with skill information (static utility method).
+ *
+ *
+ * This is a simplified helper method for cases where you don't want to use the full
+ * Advisor pattern. Useful for simple use cases or when you need manual control.
+ * @param basePrompt The base system prompt
+ * @param skillKit The SkillKit containing skill management and prompt generation
+ * @return Combined system prompt with skill information
+ */
+ public static String buildSystemPrompt(String basePrompt, SkillKit skillKit) {
+ String skillPrompt = skillKit.getSkillSystemPrompt();
+
+ if (skillPrompt == null || skillPrompt.isEmpty()) {
+ return basePrompt;
+ }
+
+ if (basePrompt == null || basePrompt.isEmpty()) {
+ return skillPrompt;
+ }
+
+ return basePrompt + "\n\n" + skillPrompt;
+ }
+
+ /**
+ * Cleanup skills after conversation (static utility method).
+ *
+ *
+ * This is a simplified helper method for cases where you don't want to use the full
+ * Advisor pattern. When using the Advisor pattern, cleanup is automatic.
+ * @param skillKit The SkillKit to clean up
+ */
+ public static void cleanupSkills(SkillKit skillKit) {
+ skillKit.deactivateAllSkills();
+ }
+
+ @Override
+ public String getName() {
+ return "SpringAiSkillAdvisor";
+ }
+
+ @Override
+ public int getOrder() {
+ return 0; // Execute first to inject system prompt
+ }
+
+ @Override
+ public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
+ ChatClientRequest modifiedRequest = this.injectSkillSystemPrompt(request);
+
+ try {
+ ChatClientResponse response = chain.nextCall(modifiedRequest);
+ cleanupSkills(this.skillKit);
+ return response;
+ }
+ catch (Exception e) {
+ logger.error("Error in skill advisor", e);
+ cleanupSkills(this.skillKit);
+ throw e;
+ }
+ }
+
+ @Override
+ public Flux adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
+ ChatClientRequest modifiedRequest = this.injectSkillSystemPrompt(request);
+ Flux responses = chain.nextStream(modifiedRequest);
+ return new ChatClientMessageAggregator().aggregateChatClientResponse(responses,
+ response -> cleanupSkills(this.skillKit));
+ }
+
+ // ==================== Static Helper Methods (Extension API) ====================
+
+ /**
+ * Inject skill system prompt into the request.
+ *
+ *
+ * This method:
+ *
+ * Extracts base system prompt from request
+ * Combines it with skill system prompt using buildSystemPrompt()
+ * Returns modified request with updated system message
+ *
+ *
+ *
+ * Note: Tool injection is handled by
+ * {@code SkillAwareToolCallingManager.resolveToolDefinitions()}, not by this Advisor.
+ * @param request The original request
+ * @return Modified request with skill prompt
+ */
+ private ChatClientRequest injectSkillSystemPrompt(ChatClientRequest request) {
+ // 1. Extract and combine system prompt
+ List messages = new ArrayList<>(request.prompt().getInstructions());
+
+ String basePrompt = "";
+ boolean hasSystemMessage = false;
+ int systemMessageIndex = -1;
+
+ for (int i = 0; i < messages.size(); i++) {
+ if (messages.get(i) instanceof SystemMessage systemMessage) {
+ String text = systemMessage.getText();
+ basePrompt = (text != null) ? text : "";
+ hasSystemMessage = true;
+ systemMessageIndex = i;
+ break;
+ }
+ }
+
+ // Build combined prompt with skill information
+ String combinedPrompt = buildSystemPrompt(basePrompt, this.skillKit);
+
+ // Replace existing system message or add new one
+ if (hasSystemMessage) {
+ messages.set(systemMessageIndex, new SystemMessage(combinedPrompt));
+ }
+ else {
+ messages.add(0, new SystemMessage(combinedPrompt));
+ }
+
+ // 2. Create modified request with updated prompt
+ // Note: Tools are NOT injected here - SkillAwareToolCallingManager handles that!
+ ChatOptions originalOptions = request.prompt().getOptions();
+ return request.mutate().prompt(new Prompt(messages, originalOptions)).build();
+ }
+
+ /**
+ * Builder for SkillAwareAdvisor.
+ */
+ public static class Builder {
+
+ private @Nullable SkillKit skillKit;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the skill kit.
+ * @param skillKit skill kit instance
+ * @return this builder
+ */
+ public Builder skillKit(SkillKit skillKit) {
+ this.skillKit = skillKit;
+ return this;
+ }
+
+ /**
+ * Builds the SkillAwareAdvisor instance.
+ * @return new SkillAwareAdvisor instance
+ */
+ public SkillAwareAdvisor build() {
+ if (this.skillKit == null) {
+ throw new IllegalArgumentException("skillKit cannot be null");
+ }
+ return new SkillAwareAdvisor(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallbackResolver.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallbackResolver.java
new file mode 100644
index 00000000000..d09aa14d333
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallbackResolver.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.spi;
+
+import java.util.List;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.skill.core.SkillKit;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.resolution.ToolCallbackResolver;
+
+/**
+ * Skill-aware ToolCallbackResolver - dynamically resolves tools from SkillKit.
+ *
+ *
+ * This resolver works with {@code SkillAwareToolCallingManager} to enable dynamic tool
+ * execution. When the LLM calls a tool, this resolver finds the tool callback from the
+ * SkillKit's active tools.
+ *
+ * @see ToolCallbackResolver
+ * @see SkillKit
+ */
+public class SkillAwareToolCallbackResolver implements ToolCallbackResolver {
+
+ private static final Logger logger = LoggerFactory.getLogger(SkillAwareToolCallbackResolver.class);
+
+ private final SkillKit skillKit;
+
+ private SkillAwareToolCallbackResolver(SkillKit skillKit) {
+ this.skillKit = skillKit;
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ public @Nullable ToolCallback resolve(String toolName) {
+ if (this.skillKit == null) {
+ logger.warn("No SkillKit configured");
+ return null;
+ }
+
+ List activeSkillCallbacks = this.skillKit.getAllActiveTools();
+ for (ToolCallback callback : activeSkillCallbacks) {
+ if (callback != null && callback.getToolDefinition() != null
+ && toolName.equals(callback.getToolDefinition().name())) {
+ return callback;
+ }
+ }
+
+ List defaultSkillTools = this.skillKit.getSkillLoaderTools();
+ for (ToolCallback callback : defaultSkillTools) {
+ if (callback != null && callback.getToolDefinition() != null
+ && toolName.equals(callback.getToolDefinition().name())) {
+ return callback;
+ }
+ }
+
+ logger.warn("Tool '{}' not found", toolName);
+ return null;
+ }
+
+ /**
+ * Builder for SkillAwareToolCallbackResolver.
+ */
+ public static class Builder {
+
+ private @Nullable SkillKit skillKit;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the skill kit.
+ * @param skillKit skill kit instance
+ * @return this builder
+ */
+ public Builder skillKit(SkillKit skillKit) {
+ this.skillKit = skillKit;
+ return this;
+ }
+
+ /**
+ * Builds the SkillAwareToolCallbackResolver instance.
+ * @return new SkillAwareToolCallbackResolver instance
+ */
+ public SkillAwareToolCallbackResolver build() {
+ if (this.skillKit == null) {
+ throw new IllegalArgumentException("skillKit cannot be null");
+ }
+ return new SkillAwareToolCallbackResolver(this.skillKit);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallingManager.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallingManager.java
new file mode 100644
index 00000000000..9b3973a3e11
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallingManager.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.spi;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.DefaultToolCallingManager;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.model.tool.ToolCallingManager;
+import org.springframework.ai.model.tool.ToolExecutionResult;
+import org.springframework.ai.skill.core.SkillKit;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver;
+
+/**
+ * Skill-aware ToolCallingManager that automatically merges skill tools with base tools.
+ *
+ *
+ * Why We Need This:
+ *
+ *
+ * We implement {@code ToolCallingManager} interface and delegate to the underlying
+ * {@code ToolCallingManager} implementation while adding skill tool merging logic.
+ *
+ *
+ * How It Works:
+ *
+ *
+ * This class works together with {@code SkillAwareToolCallbackResolver}:
+ *
+ *
+ * 1. resolveToolDefinitions() - Provides tool definitions to LLM:
+ * - Delegate to underlying ToolCallingManager to resolve base tool definitions
+ * - Get default skill tools from skillKit.getSkillTools()
+ * - Get active skill tools from skillKit.getAllActiveTools()
+ * - Merge with deduplication by tool name (priority: base > default > active)
+ * - Return merged ToolDefinition list
+ *
+ * 2. executeToolCalls() - Executes tool calls from LLM:
+ * - Delegates to the underlying ToolCallingManager
+ * - The delegate uses SkillAwareToolCallbackResolver
+ * - SkillAwareToolCallbackResolver finds tools in skillKit.getAllActiveTools()
+ *
+ *
+ * @see ToolCallingManager
+ * @see SkillKit
+ * @since 1.1.3
+ */
+public class SkillAwareToolCallingManager implements ToolCallingManager {
+
+ private static final Logger logger = LoggerFactory.getLogger(SkillAwareToolCallingManager.class);
+
+ private final SkillKit skillKit;
+
+ private final ToolCallingManager delegate;
+
+ /**
+ * Create a SkillAwareToolCallingManager with custom delegate.
+ * @param skillKit The SkillKit containing skill management and tools
+ * @param delegate The underlying ToolCallingManager to delegate to
+ */
+ public SkillAwareToolCallingManager(SkillKit skillKit, ToolCallingManager delegate) {
+ this.skillKit = skillKit;
+ this.delegate = delegate;
+ }
+
+ /**
+ * Builder for SkillAwareToolCallingManager.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Resolve tool definitions by merging base tools with skill tools.
+ *
+ *
+ * This is the KEY method where we inject skill tools dynamically!
+ * @param chatOptions The chat options containing base tools
+ * @return Merged list of tool definitions (base + skill tools)
+ */
+ @Override
+ public List resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
+ // Delegate to the underlying ToolCallingManager to resolve base definitions
+ List baseDefinitions = this.delegate.resolveToolDefinitions(chatOptions);
+
+ if (this.skillKit == null) {
+ logger.warn("No SkillKit configured, returning base tools only");
+ return baseDefinitions != null ? baseDefinitions : List.of();
+ }
+
+ List defaultSkillToolCallbacks = this.skillKit.getSkillLoaderTools();
+ List defaultSkillToolDefinitions = new ArrayList<>();
+ for (ToolCallback callback : defaultSkillToolCallbacks) {
+ if (callback != null && callback.getToolDefinition() != null) {
+ defaultSkillToolDefinitions.add(callback.getToolDefinition());
+ }
+ }
+
+ List activeSkillCallbacks = this.skillKit.getAllActiveTools();
+
+ List skillDefinitions = new ArrayList<>();
+ for (ToolCallback callback : activeSkillCallbacks) {
+ if (callback != null && callback.getToolDefinition() != null) {
+ skillDefinitions.add(callback.getToolDefinition());
+ }
+ }
+
+ return this.mergeToolDefinitions(baseDefinitions, defaultSkillToolDefinitions, skillDefinitions);
+ }
+
+ /**
+ * Merge tool definitions with deduplication by tool name.
+ *
+ *
+ * Priority order : base > default > skills
+ *
+ * Base tools take the highest precedence (from chatOptions)
+ * Default SkillTools added next (for progressive loading)
+ * Skill tools added last (from activated skills)
+ *
+ * @param baseDefinitions Base tool definitions from chatOptions
+ * @param defaultSkillToolDefinitions Default SkillTools for progressive loading
+ * @param skillDefinitions Skill tool definitions from activated skills
+ * @return Merged list with no duplicates
+ */
+ private List mergeToolDefinitions(List baseDefinitions,
+ List defaultSkillToolDefinitions, List skillDefinitions) {
+
+ List merged = new ArrayList<>();
+ Set toolNames = new HashSet<>();
+
+ for (ToolDefinition def : baseDefinitions) {
+ if (def != null && def.name() != null) {
+ merged.add(def);
+ toolNames.add(def.name());
+ }
+ }
+
+ for (ToolDefinition def : defaultSkillToolDefinitions) {
+ if (def != null && def.name() != null) {
+ if (!toolNames.contains(def.name())) {
+ merged.add(def);
+ toolNames.add(def.name());
+ }
+ }
+ }
+
+ for (ToolDefinition def : skillDefinitions) {
+ if (def != null && def.name() != null) {
+ if (!toolNames.contains(def.name())) {
+ merged.add(def);
+ toolNames.add(def.name());
+ }
+ }
+ }
+
+ return merged;
+ }
+
+ /**
+ * Execute tool calls - delegate to the underlying ToolCallingManager.
+ *
+ *
+ * Tool execution logic is delegated to the underlying manager. The delegate uses
+ * {@code
+ * SkillAwareToolCallbackResolver} to find tool callbacks from both chatOptions and
+ * skillBox.getAllActiveTools().
+ * @param prompt The prompt
+ * @param chatResponse The chat response containing tool calls
+ * @return Tool execution result
+ */
+ @Override
+ public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
+ return this.delegate.executeToolCalls(prompt, chatResponse);
+ }
+
+ public static class Builder {
+
+ private @Nullable SkillKit skillKit;
+
+ private @Nullable ToolCallingManager delegate;
+
+ public Builder skillKit(SkillKit skillKit) {
+ this.skillKit = skillKit;
+ return this;
+ }
+
+ public Builder delegate(ToolCallingManager delegate) {
+ this.delegate = delegate;
+ return this;
+ }
+
+ public SkillAwareToolCallingManager build() {
+ if (this.skillKit == null) {
+ throw new IllegalArgumentException("skillKit cannot be null");
+ }
+ ToolCallingManager delegateManager = this.delegate;
+ if (delegateManager == null) {
+ // Create delegate with SkillAwareToolCallbackResolver
+ delegateManager = DefaultToolCallingManager.builder()
+ .toolCallbackResolver(new DelegatingToolCallbackResolver(
+ List.of(SkillAwareToolCallbackResolver.builder().skillKit(this.skillKit).build())))
+ .build();
+ }
+ return new SkillAwareToolCallingManager(this.skillKit, delegateManager);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/package-info.java
new file mode 100644
index 00000000000..a8d2dceee67
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Service provider interfaces for Spring AI integration and extensibility.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.spi;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillIdGenerator.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillIdGenerator.java
new file mode 100644
index 00000000000..2b4a4b1099d
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillIdGenerator.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.support;
+
+import java.util.Objects;
+
+import org.springframework.ai.skill.core.SkillIdGenerator;
+import org.springframework.ai.skill.core.SkillMetadata;
+
+/**
+ * Default skill ID generator using "{name}_{source}" format.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class DefaultSkillIdGenerator implements SkillIdGenerator {
+
+ /**
+ * Generates skill ID in format: {name}_{source}.
+ * @param metadata skill metadata
+ * @return generated skill ID
+ * @throws IllegalArgumentException if metadata invalid
+ */
+ @Override
+ public String generateId(SkillMetadata metadata) {
+ Objects.requireNonNull(metadata, "metadata cannot be null");
+
+ String name = metadata.getName();
+ String source = metadata.getSource();
+
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException("metadata.name cannot be null or empty");
+ }
+
+ if (source == null || source.trim().isEmpty()) {
+ throw new IllegalArgumentException("metadata.source cannot be null or empty");
+ }
+
+ return name + "_" + source;
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillPoolManager.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillPoolManager.java
new file mode 100644
index 00000000000..5d51ff78aba
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillPoolManager.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.support;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.skill.common.LoadStrategy;
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillDefinition;
+import org.springframework.ai.skill.core.SkillPoolManager;
+import org.springframework.ai.skill.exception.SkillLoadException;
+import org.springframework.ai.skill.exception.SkillNotFoundException;
+
+/**
+ * Default implementation of SkillPoolManager with thread-safe caching.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class DefaultSkillPoolManager implements SkillPoolManager {
+
+ private static final Logger logger = LoggerFactory.getLogger(DefaultSkillPoolManager.class);
+
+ private final ConcurrentHashMap definitions = new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap skillPool = new ConcurrentHashMap<>();
+
+ @Override
+ public void registerDefinition(SkillDefinition definition) {
+ Objects.requireNonNull(definition, "definition cannot be null");
+
+ String skillId = definition.getSkillId();
+
+ if (this.definitions.containsKey(skillId)) {
+ throw new IllegalArgumentException("Skill with ID '" + skillId + "' is already registered");
+ }
+
+ this.definitions.put(skillId, definition);
+
+ if (definition.getLoadStrategy() == LoadStrategy.EAGER) {
+ try {
+ Skill skill = this.createInstance(definition);
+ this.skillPool.put(skillId, skill);
+ }
+ catch (Exception e) {
+ this.definitions.remove(skillId);
+ throw new SkillLoadException(skillId, "EAGER loading failed during registration", e);
+ }
+ }
+ }
+
+ @Override
+ public @Nullable SkillDefinition getDefinition(String skillId) {
+ return this.definitions.get(skillId);
+ }
+
+ @Override
+ public boolean hasDefinition(String skillId) {
+ return this.definitions.containsKey(skillId);
+ }
+
+ @Override
+ public Skill load(String skillId) {
+ Objects.requireNonNull(skillId, "skillId cannot be null");
+
+ SkillDefinition definition = this.definitions.get(skillId);
+ if (definition == null) {
+ throw new SkillNotFoundException(skillId);
+ }
+
+ return this.doLoad(skillId, definition);
+ }
+
+ private Skill doLoad(String skillId, SkillDefinition definition) {
+ return this.skillPool.computeIfAbsent(skillId, id -> this.createInstance(definition));
+ }
+
+ private Skill createInstance(SkillDefinition definition) {
+ try {
+ Skill skill = definition.getLoader().get();
+ if (skill == null) {
+ throw new NullPointerException("Loader returned null");
+ }
+ return skill;
+ }
+ catch (Exception e) {
+ throw new SkillLoadException(definition.getSkillId(), "Loader threw exception", e);
+ }
+ }
+
+ @Override
+ public List getDefinitions() {
+ return List.copyOf(this.definitions.values());
+ }
+
+ /**
+ * Gets skill definitions by source.
+ * @param source source identifier
+ * @return list of skill definitions
+ */
+ public List getDefinitionsBySource(String source) {
+ Objects.requireNonNull(source, "source cannot be null");
+
+ return this.definitions.values()
+ .stream()
+ .filter(def -> source.equals(def.getSource()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Gets skill definitions by name.
+ * @param name skill name
+ * @return list of skill definitions
+ */
+ public List getDefinitionsByName(String name) {
+ Objects.requireNonNull(name, "name cannot be null");
+
+ return this.definitions.values()
+ .stream()
+ .filter(def -> name.equals(def.getMetadata().getName()))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void evict(String skillId) {
+ this.skillPool.remove(skillId);
+ }
+
+ @Override
+ public void evictAll() {
+ this.skillPool.clear();
+ }
+
+ @Override
+ public void unregister(String skillId) {
+ Objects.requireNonNull(skillId, "skillId cannot be null");
+
+ this.skillPool.remove(skillId);
+
+ SkillDefinition removedDefinition = this.definitions.remove(skillId);
+ if (removedDefinition == null) {
+ logger.warn("Attempted to unregister non-existent skill: {}", skillId);
+ }
+ }
+
+ @Override
+ public void clear() {
+ this.definitions.clear();
+ this.skillPool.clear();
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/SimpleSkillBox.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/SimpleSkillBox.java
new file mode 100644
index 00000000000..73111688af9
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/SimpleSkillBox.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.support;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.skill.core.SkillBox;
+import org.springframework.ai.skill.core.SkillMetadata;
+
+/**
+ * Default implementation of SkillBox with thread-safe metadata storage.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SimpleSkillBox implements SkillBox {
+
+ private static final Logger logger = LoggerFactory.getLogger(SimpleSkillBox.class);
+
+ private final ConcurrentHashMap skills = new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap activated = new ConcurrentHashMap<>();
+
+ private final List sources = new ArrayList<>();
+
+ public SimpleSkillBox() {
+ this.sources.add("custom");
+ }
+
+ @Override
+ public void addSkill(String name, SkillMetadata metadata) {
+ Objects.requireNonNull(name, "name cannot be null");
+ Objects.requireNonNull(metadata, "metadata cannot be null");
+
+ if (this.skills.containsKey(name)) {
+ throw new IllegalArgumentException("Skill with name '" + name + "' already exists in this SkillBox");
+ }
+
+ this.skills.put(name, metadata);
+ this.addSource(metadata.getSource());
+ this.activated.putIfAbsent(name, false);
+ }
+
+ @Override
+ public @Nullable SkillMetadata getMetadata(String name) {
+ return this.skills.get(name);
+ }
+
+ @Override
+ public Map getAllMetadata() {
+ return Collections.unmodifiableMap(this.skills);
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return this.skills.containsKey(name);
+ }
+
+ @Override
+ public int getSkillCount() {
+ return this.skills.size();
+ }
+
+ @Override
+ public void activateSkill(String name) {
+ this.activated.put(name, true);
+ }
+
+ @Override
+ public void deactivateSkill(String name) {
+ this.activated.put(name, false);
+ }
+
+ @Override
+ public void deactivateAllSkills() {
+ this.activated.replaceAll((k, v) -> false);
+ }
+
+ @Override
+ public boolean isActivated(String name) {
+ return this.activated.getOrDefault(name, false);
+ }
+
+ @Override
+ public Set getActivatedSkillNames() {
+ return this.activated.entrySet()
+ .stream()
+ .filter(Map.Entry::getValue)
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public List getSources() {
+ return this.sources;
+ }
+
+ public void setSources(List sourceList) {
+ this.sources.clear();
+ this.sources.addAll(sourceList);
+ }
+
+ public void addSource(String source) {
+ if (!this.sources.contains(source)) {
+ this.sources.add(source);
+ }
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/package-info.java
new file mode 100644
index 00000000000..b355eaaebec
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support classes providing default implementations of core interfaces.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.support;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/SimpleSkillLoaderTools.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/SimpleSkillLoaderTools.java
new file mode 100644
index 00000000000..846cc12d7d9
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/SimpleSkillLoaderTools.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.tool;
+
+import java.util.Map;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.skill.capability.ReferencesLoader;
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillKit;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+
+/**
+ * Tool for progressive skill loading via Spring AI @Tool annotations.
+ *
+ *
+ * Provides loadSkillContent and loadSkillReference methods for LLM to activate and access
+ * skills.
+ *
+ * @author LinPeng Zhang
+ * @since 1.1.3
+ */
+public class SimpleSkillLoaderTools {
+
+ private static final Logger logger = LoggerFactory.getLogger(SimpleSkillLoaderTools.class);
+
+ private final SkillKit skillKit;
+
+ private SimpleSkillLoaderTools(Builder builder) {
+ this.skillKit = Objects.requireNonNull(builder.skillKit, "skillKit cannot be null");
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Loads skill content by name, activates skill, and returns its documentation.
+ * @param skillName skill name
+ * @return skill content or error message
+ */
+ @Tool(description = "Load the content of a skill by its name. "
+ + "This activates the skill and returns its documentation. "
+ + "Use this when you need to understand what a skill does and how to use it.")
+ public String loadSkillContent(@ToolParam(description = "The name of the skill to load") String skillName) {
+ try {
+ if (skillName == null || skillName.trim().isEmpty()) {
+ return "Error: Missing or empty skill name";
+ }
+
+ if (!this.skillKit.exists(skillName)) {
+ return "Error: Skill not found in SkillBox: " + skillName
+ + ". Please use a skill that has been added to the SkillBox.";
+ }
+
+ this.skillKit.activateSkill(skillName);
+
+ Skill skill = this.skillKit.getSkillByName(skillName);
+ if (skill == null) {
+ return "Error: Skill not found: " + skillName;
+ }
+
+ return skill.getContent();
+
+ }
+ catch (Exception e) {
+ logger.error("Error loading skill: {}", skillName, e);
+ return "Error loading skill: " + e.getMessage();
+ }
+ }
+
+ /**
+ * Loads specific reference from skill using reference key.
+ * @param skillName skill name with references
+ * @param referenceKey reference key from skill content
+ * @return reference content (URL, file path, or text) or error message
+ */
+ @Tool(description = "ONLY use this tool when a skill's content explicitly mentions it has reference materials. "
+ + "Load a specific reference from a skill using the reference key mentioned in the skill's content. "
+ + "Returns reference content (URL, file path, or text string). "
+ + "Do NOT use this for regular skill operations - use skill's own tools instead.")
+ public String loadSkillReference(@ToolParam(description = "The skill name that has references") String skillName,
+ @ToolParam(description = "The exact reference key mentioned in the skill's content") String referenceKey) {
+ try {
+ if (skillName == null || skillName.trim().isEmpty()) {
+ return "Error: Missing or empty skill name";
+ }
+
+ if (referenceKey == null || referenceKey.trim().isEmpty()) {
+ return "Error: Missing or empty reference key";
+ }
+
+ if (!this.skillKit.exists(skillName)) {
+ return "Error: Skill not found in SkillBox: " + skillName
+ + ". Please use a skill that has been added to the SkillBox.";
+ }
+
+ Skill skill = this.skillKit.getSkillByName(skillName);
+ if (skill == null) {
+ return "Error: Skill not found: " + skillName;
+ }
+
+ if (!skill.supports(ReferencesLoader.class)) {
+ return "Error: Skill '" + skillName + "' does not have references. ";
+ }
+
+ ReferencesLoader loader = skill.as(ReferencesLoader.class);
+ Map references = loader.getReferences();
+
+ if (!references.containsKey(referenceKey)) {
+ return "Error: Reference key '" + referenceKey + "' not found in skill '" + skillName + "'. "
+ + "Available keys: " + references.keySet();
+ }
+
+ return references.get(referenceKey);
+
+ }
+ catch (Exception e) {
+ logger.error("Error loading skill reference: skill={}, key={}", skillName, referenceKey, e);
+ return "Error loading skill reference: " + e.getMessage();
+ }
+ }
+
+ /**
+ * Builder for SimpleSkillLoaderTool.
+ */
+ public static class Builder {
+
+ private @Nullable SkillKit skillKit;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the skill kit.
+ * @param skillKit skill kit instance
+ * @return this builder
+ */
+ public Builder skillKit(SkillKit skillKit) {
+ this.skillKit = skillKit;
+ return this;
+ }
+
+ /**
+ * Builds the SimpleSkillLoaderTool instance.
+ * @return new SimpleSkillLoaderTool instance
+ */
+ public SimpleSkillLoaderTools build() {
+ if (this.skillKit == null) {
+ throw new IllegalArgumentException("skillKit cannot be null");
+ }
+ return new SimpleSkillLoaderTools(this);
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/package-info.java
new file mode 100644
index 00000000000..c23e1ad5d07
--- /dev/null
+++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Tool implementations for progressive skill loading and management.
+ */
+
+@NullMarked
+package org.springframework.ai.skill.tool;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/ClassBasedSkillRegistrarTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/ClassBasedSkillRegistrarTests.java
new file mode 100644
index 00000000000..ec2dec87139
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/ClassBasedSkillRegistrarTests.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.skill.core.SkillDefinition;
+import org.springframework.ai.skill.core.SkillPoolManager;
+import org.springframework.ai.skill.fixtures.WeatherSkill;
+import org.springframework.ai.skill.registration.ClassBasedSkillRegistrar;
+import org.springframework.ai.skill.support.DefaultSkillPoolManager;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link ClassBasedSkillRegistrar}.
+ *
+ * @author LinPeng Zhang
+ */
+class ClassBasedSkillRegistrarTests {
+
+ private ClassBasedSkillRegistrar registrar;
+
+ private SkillPoolManager poolManager;
+
+ @BeforeEach
+ void setUp() {
+ this.registrar = ClassBasedSkillRegistrar.builder().build();
+ this.poolManager = new DefaultSkillPoolManager();
+ }
+
+ @Test
+ void registerShouldRegisterValidSkillClass() {
+ SkillDefinition definition = this.registrar.register(this.poolManager, WeatherSkill.class);
+
+ assertThat(definition).isNotNull();
+ assertThat(definition.getMetadata().getName()).isEqualTo("weather");
+ assertThat(this.poolManager.hasDefinition(definition.getSkillId())).isTrue();
+ }
+
+ @Test
+ void registerShouldValidateInputs() {
+ assertThatThrownBy(() -> this.registrar.register(null, WeatherSkill.class))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> this.registrar.register(this.poolManager, null))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> this.registrar.register(this.poolManager, String.class))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("@Skill");
+ }
+
+ @Test
+ void supportsShouldIdentifyValidClasses() {
+ assertThat(this.registrar.supports(WeatherSkill.class)).isTrue();
+ assertThat(this.registrar.supports(String.class)).isFalse();
+ assertThat(this.registrar.supports("not a class")).isFalse();
+ assertThat(this.registrar.supports(null)).isFalse();
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillKitTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillKitTests.java
new file mode 100644
index 00000000000..f9a32b60efa
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillKitTests.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.skill.core.DefaultSkillKit;
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillBox;
+import org.springframework.ai.skill.core.SkillMetadata;
+import org.springframework.ai.skill.core.SkillPoolManager;
+import org.springframework.ai.skill.exception.SkillRegistrationException;
+import org.springframework.ai.skill.exception.SkillValidationException;
+import org.springframework.ai.skill.fixtures.CalculatorSkill;
+import org.springframework.ai.skill.fixtures.WeatherSkill;
+import org.springframework.ai.skill.support.DefaultSkillPoolManager;
+import org.springframework.ai.skill.support.SimpleSkillBox;
+import org.springframework.ai.tool.ToolCallback;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link DefaultSkillKit}.
+ *
+ * @author LinPeng Zhang
+ */
+class DefaultSkillKitTests {
+
+ private DefaultSkillKit skillKit;
+
+ private SkillPoolManager poolManager;
+
+ private SkillBox skillBox;
+
+ @BeforeEach
+ void setUp() {
+ this.poolManager = new DefaultSkillPoolManager();
+ SimpleSkillBox simpleSkillBox = new SimpleSkillBox();
+ simpleSkillBox.setSources(List.of("custom", "example"));
+ this.skillBox = simpleSkillBox;
+ this.skillKit = DefaultSkillKit.builder().skillBox(this.skillBox).poolManager(this.poolManager).build();
+ }
+
+ @Test
+ void registerShouldValidateInputsAndRejectDuplicates() {
+ SkillMetadata metadata = SkillMetadata.builder("test", "description", "custom").build();
+
+ assertThatThrownBy(() -> this.skillKit.register(null, () -> new TestSkill(metadata)))
+ .isInstanceOf(SkillValidationException.class)
+ .hasMessageContaining("metadata cannot be null");
+ assertThatThrownBy(() -> this.skillKit.register(metadata, null)).isInstanceOf(SkillValidationException.class)
+ .hasMessageContaining("loader cannot be null");
+
+ this.skillKit.register(metadata, () -> new TestSkill(metadata));
+ assertThatThrownBy(() -> this.skillKit.register(metadata, () -> new TestSkill(metadata)))
+ .isInstanceOf(SkillRegistrationException.class)
+ .hasMessageContaining("already registered");
+ }
+
+ @Test
+ void registerFromInstanceShouldRegisterAnnotatedSkill() {
+ this.skillKit.register(new CalculatorSkill());
+
+ assertThat(this.skillKit.exists("calculator")).isTrue();
+ assertThat(this.skillKit.getMetadata("calculator")).isNotNull();
+ }
+
+ @Test
+ void registerFromClassShouldRegisterClass() {
+ this.skillKit.register(WeatherSkill.class);
+
+ assertThat(this.skillKit.exists("weather")).isTrue();
+ Skill skill = this.skillKit.getSkillByName("weather");
+ assertThat(skill).isNotNull();
+ assertThat(skill.getTools()).isNotEmpty();
+ }
+
+ @Test
+ void deactivateAllSkillsShouldClearAllActivation() {
+ this.skillKit.register(new CalculatorSkill());
+ this.skillKit.register(WeatherSkill.class);
+ this.skillKit.activateSkill("calculator");
+ this.skillKit.activateSkill("weather");
+
+ this.skillKit.deactivateAllSkills();
+
+ assertThat(this.skillKit.isActivated("calculator")).isFalse();
+ assertThat(this.skillKit.isActivated("weather")).isFalse();
+ }
+
+ @Test
+ void getSkillByNameShouldReturnSkillOrNull() {
+ this.skillKit.register(new CalculatorSkill());
+
+ Skill skill = this.skillKit.getSkillByName("calculator");
+ assertThat(skill).isNotNull();
+ assertThat(skill.getMetadata().getName()).isEqualTo("calculator");
+
+ Skill nonexistent = this.skillKit.getSkillByName("nonexistent");
+ assertThat(nonexistent).isNull();
+ }
+
+ @Test
+ void getAllActiveToolsShouldReturnToolsFromActivatedSkills() {
+ this.skillKit.register(WeatherSkill.class);
+ this.skillKit.activateSkill("weather");
+
+ List tools = this.skillKit.getAllActiveTools();
+
+ assertThat(tools).isNotEmpty();
+ }
+
+ @Test
+ void getSkillSystemPromptShouldGeneratePromptContent() {
+ String emptyPrompt = this.skillKit.getSkillSystemPrompt();
+ assertThat(emptyPrompt).isEmpty();
+
+ this.skillKit.register(new CalculatorSkill());
+ String prompt = this.skillKit.getSkillSystemPrompt();
+ assertThat(prompt).contains("calculator").contains("loadSkillContent");
+ }
+
+ @Test
+ void multiTenantShouldIsolateBoxButSharePoolManager() {
+ SkillBox tenant1Box = new SimpleSkillBox();
+ SkillBox tenant2Box = new SimpleSkillBox();
+ SkillPoolManager sharedPoolManager = new DefaultSkillPoolManager();
+
+ DefaultSkillKit tenant1Kit = DefaultSkillKit.builder()
+ .skillBox(tenant1Box)
+ .poolManager(sharedPoolManager)
+ .build();
+ DefaultSkillKit tenant2Kit = DefaultSkillKit.builder()
+ .skillBox(tenant2Box)
+ .poolManager(sharedPoolManager)
+ .build();
+
+ tenant1Kit.register(new CalculatorSkill());
+
+ assertThat(tenant1Kit.exists("calculator")).isTrue();
+ assertThat(tenant2Kit.exists("calculator")).isFalse();
+ }
+
+ @Test
+ void multiTenantShouldIsolateActivationState() {
+ SimpleSkillBox tenant1Box = new SimpleSkillBox();
+ tenant1Box.setSources(List.of("example"));
+ SimpleSkillBox tenant2Box = new SimpleSkillBox();
+ tenant2Box.setSources(List.of("example"));
+ SkillPoolManager sharedPoolManager = new DefaultSkillPoolManager();
+
+ DefaultSkillKit tenant1Kit = DefaultSkillKit.builder()
+ .skillBox(tenant1Box)
+ .poolManager(sharedPoolManager)
+ .build();
+ DefaultSkillKit tenant2Kit = DefaultSkillKit.builder()
+ .skillBox(tenant2Box)
+ .poolManager(sharedPoolManager)
+ .build();
+
+ tenant1Kit.register(new CalculatorSkill());
+ tenant1Kit.activateSkill("calculator");
+
+ tenant2Kit.addSkillToBox("calculator");
+
+ assertThat(tenant1Kit.isActivated("calculator")).isTrue();
+ assertThat(tenant2Kit.isActivated("calculator")).isFalse();
+ }
+
+ @Test
+ void multiTenantShouldShareSingletonInstances() {
+ SimpleSkillBox tenant1Box = new SimpleSkillBox();
+ tenant1Box.setSources(List.of("example"));
+ SimpleSkillBox tenant2Box = new SimpleSkillBox();
+ tenant2Box.setSources(List.of("example"));
+ SkillPoolManager sharedPoolManager = new DefaultSkillPoolManager();
+
+ DefaultSkillKit tenant1Kit = DefaultSkillKit.builder()
+ .skillBox(tenant1Box)
+ .poolManager(sharedPoolManager)
+ .build();
+ DefaultSkillKit tenant2Kit = DefaultSkillKit.builder()
+ .skillBox(tenant2Box)
+ .poolManager(sharedPoolManager)
+ .build();
+
+ tenant1Kit.register(new CalculatorSkill());
+ tenant2Kit.addSkillToBox("calculator");
+
+ Skill skill1 = tenant1Kit.getSkillByName("calculator");
+ Skill skill2 = tenant2Kit.getSkillByName("calculator");
+
+ assertThat(skill1).isSameAs(skill2);
+ }
+
+ private static class TestSkill implements Skill {
+
+ private final SkillMetadata metadata;
+
+ TestSkill(SkillMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ @Override
+ public SkillMetadata getMetadata() {
+ return this.metadata;
+ }
+
+ @Override
+ public String getContent() {
+ return "Test content";
+ }
+
+ @Override
+ public List getTools() {
+ return List.of();
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillPoolManagerTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillPoolManagerTests.java
new file mode 100644
index 00000000000..cab021eaaaf
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillPoolManagerTests.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.skill.common.LoadStrategy;
+import org.springframework.ai.skill.core.Skill;
+import org.springframework.ai.skill.core.SkillDefinition;
+import org.springframework.ai.skill.core.SkillMetadata;
+import org.springframework.ai.skill.exception.SkillLoadException;
+import org.springframework.ai.skill.exception.SkillNotFoundException;
+import org.springframework.ai.skill.support.DefaultSkillPoolManager;
+import org.springframework.ai.tool.ToolCallback;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link DefaultSkillPoolManager}.
+ *
+ * @author LinPeng Zhang
+ */
+class DefaultSkillPoolManagerTests {
+
+ private DefaultSkillPoolManager poolManager;
+
+ private SkillMetadata testMetadata;
+
+ @BeforeEach
+ void setUp() {
+ this.poolManager = new DefaultSkillPoolManager();
+ this.testMetadata = SkillMetadata.builder("test", "Test skill", "spring").build();
+ }
+
+ @Test
+ void registerDefinitionShouldStoreDefinitionAndRejectDuplicates() {
+ SkillDefinition definition = createDefinition("test_spring");
+
+ this.poolManager.registerDefinition(definition);
+
+ assertThat(this.poolManager.hasDefinition("test_spring")).isTrue();
+ assertThat(this.poolManager.getDefinition("test_spring")).isNotNull();
+
+ assertThatThrownBy(() -> this.poolManager.registerDefinition(definition))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void lazyLoadingShouldCacheInstancesOnFirstCall() {
+ SkillDefinition definition = createDefinition("test_spring", LoadStrategy.LAZY);
+ this.poolManager.registerDefinition(definition);
+
+ Skill skill1 = this.poolManager.load("test_spring");
+ Skill skill2 = this.poolManager.load("test_spring");
+
+ assertThat(skill1).isNotNull();
+ assertThat(skill1).isSameAs(skill2);
+ }
+
+ @Test
+ void eagerLoadingShouldCacheInstancesImmediately() {
+ SkillDefinition definition = createDefinition("test_spring", LoadStrategy.EAGER);
+
+ this.poolManager.registerDefinition(definition);
+
+ Skill skill1 = this.poolManager.load("test_spring");
+ Skill skill2 = this.poolManager.load("test_spring");
+ assertThat(skill1).isSameAs(skill2);
+ }
+
+ @Test
+ void loadShouldThrowExceptionForNonexistentSkill() {
+ assertThatThrownBy(() -> this.poolManager.load("nonexistent")).isInstanceOf(SkillNotFoundException.class);
+ }
+
+ @Test
+ void loadShouldWrapLoaderExceptions() {
+ SkillDefinition definition = SkillDefinition.builder()
+ .skillId("test_spring")
+ .source("spring")
+ .metadata(this.testMetadata)
+ .loader(() -> {
+ throw new RuntimeException("Loader failed");
+ })
+ .build();
+ this.poolManager.registerDefinition(definition);
+
+ assertThatThrownBy(() -> this.poolManager.load("test_spring")).isInstanceOf(SkillLoadException.class)
+ .hasMessageContaining("Failed to load skill");
+ }
+
+ @Test
+ void evictShouldRemoveInstancesButKeepDefinitions() {
+ this.poolManager.registerDefinition(createDefinition("test1_spring"));
+ this.poolManager.registerDefinition(createDefinition("test2_spring"));
+ Skill skill1 = this.poolManager.load("test1_spring");
+
+ this.poolManager.evict("test1_spring");
+ Skill skill1Reloaded = this.poolManager.load("test1_spring");
+ assertThat(skill1).isNotSameAs(skill1Reloaded);
+ assertThat(this.poolManager.hasDefinition("test1_spring")).isTrue();
+
+ this.poolManager.evictAll();
+ skill1Reloaded = this.poolManager.load("test1_spring");
+ assertThat(this.poolManager.hasDefinition("test1_spring")).isTrue();
+ }
+
+ @Test
+ void unregisterAndClearShouldRemoveDefinitions() {
+ this.poolManager.registerDefinition(createDefinition("test1_spring"));
+ this.poolManager.registerDefinition(createDefinition("test2_spring"));
+ this.poolManager.load("test1_spring");
+
+ this.poolManager.unregister("test1_spring");
+ assertThat(this.poolManager.hasDefinition("test1_spring")).isFalse();
+ assertThatThrownBy(() -> this.poolManager.load("test1_spring")).isInstanceOf(SkillNotFoundException.class);
+
+ this.poolManager.clear();
+ assertThat(this.poolManager.getDefinitions()).isEmpty();
+ }
+
+ @Test
+ void getDefinitionsBySourceShouldFilterBySource() {
+ this.poolManager.registerDefinition(createDefinition("test1_spring", "spring"));
+ this.poolManager.registerDefinition(createDefinition("test2_spring", "spring"));
+ this.poolManager.registerDefinition(createDefinition("test3_official", "official"));
+
+ List springSkills = this.poolManager.getDefinitionsBySource("spring");
+ List officialSkills = this.poolManager.getDefinitionsBySource("official");
+
+ assertThat(springSkills).hasSize(2);
+ assertThat(officialSkills).hasSize(1);
+ }
+
+ private SkillDefinition createDefinition(String skillId) {
+ return createDefinition(skillId, LoadStrategy.LAZY);
+ }
+
+ private SkillDefinition createDefinition(String skillId, LoadStrategy strategy) {
+ return createDefinition(skillId, "spring", strategy);
+ }
+
+ private SkillDefinition createDefinition(String skillId, String source) {
+ return createDefinition(skillId, source, LoadStrategy.LAZY);
+ }
+
+ private SkillDefinition createDefinition(String skillId, String source, LoadStrategy strategy) {
+ return SkillDefinition.builder()
+ .skillId(skillId)
+ .source(source)
+ .metadata(this.testMetadata)
+ .loader(() -> new TestSkill(this.testMetadata))
+ .loadStrategy(strategy)
+ .build();
+ }
+
+ private static class TestSkill implements Skill {
+
+ private final SkillMetadata metadata;
+
+ TestSkill(SkillMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ @Override
+ public SkillMetadata getMetadata() {
+ return this.metadata;
+ }
+
+ @Override
+ public String getContent() {
+ return "Test content";
+ }
+
+ @Override
+ public List getTools() {
+ return Collections.emptyList();
+ }
+
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/InstanceBasedSkillRegistrarTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/InstanceBasedSkillRegistrarTests.java
new file mode 100644
index 00000000000..d26447aa86f
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/InstanceBasedSkillRegistrarTests.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.skill.core.SkillDefinition;
+import org.springframework.ai.skill.core.SkillPoolManager;
+import org.springframework.ai.skill.fixtures.CalculatorSkill;
+import org.springframework.ai.skill.registration.InstanceBasedSkillRegistrar;
+import org.springframework.ai.skill.support.DefaultSkillPoolManager;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link InstanceBasedSkillRegistrar}.
+ *
+ * @author LinPeng Zhang
+ */
+class InstanceBasedSkillRegistrarTests {
+
+ private InstanceBasedSkillRegistrar registrar;
+
+ private SkillPoolManager poolManager;
+
+ @BeforeEach
+ void setUp() {
+ this.registrar = InstanceBasedSkillRegistrar.builder().build();
+ this.poolManager = new DefaultSkillPoolManager();
+ }
+
+ @Test
+ void registerShouldRegisterValidSkillInstance() {
+ CalculatorSkill skill = new CalculatorSkill();
+
+ SkillDefinition definition = this.registrar.register(this.poolManager, skill);
+
+ assertThat(definition).isNotNull();
+ assertThat(definition.getMetadata().getName()).isEqualTo("calculator");
+ assertThat(this.poolManager.hasDefinition(definition.getSkillId())).isTrue();
+ }
+
+ @Test
+ void registerShouldValidateInputs() {
+ assertThatThrownBy(() -> this.registrar.register(null, new CalculatorSkill()))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> this.registrar.register(this.poolManager, null))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> this.registrar.register(this.poolManager, "not a skill"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("@Skill");
+ }
+
+ @Test
+ void supportsShouldIdentifyValidInstances() {
+ assertThat(this.registrar.supports(new CalculatorSkill())).isTrue();
+ assertThat(this.registrar.supports("not a skill")).isFalse();
+ assertThat(this.registrar.supports(null)).isFalse();
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/SimpleSkillBoxTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SimpleSkillBoxTests.java
new file mode 100644
index 00000000000..e2fc023c24f
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SimpleSkillBoxTests.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.skill.core.SkillMetadata;
+import org.springframework.ai.skill.support.SimpleSkillBox;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link SimpleSkillBox}.
+ *
+ * @author LinPeng Zhang
+ */
+class SimpleSkillBoxTests {
+
+ @Test
+ void addSkillShouldStoreSkillMetadata() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+ SkillMetadata metadata = SkillMetadata.builder("test", "description", "source").build();
+
+ skillBox.addSkill("test", metadata);
+
+ assertThat(skillBox.exists("test")).isTrue();
+ assertThat(skillBox.getMetadata("test")).isEqualTo(metadata);
+ assertThat(skillBox.getSkillCount()).isEqualTo(1);
+ }
+
+ @Test
+ void addSkillShouldValidateInputs() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+ SkillMetadata metadata = SkillMetadata.builder("test", "description", "source").build();
+
+ assertThatThrownBy(() -> skillBox.addSkill(null, metadata)).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> skillBox.addSkill("test", null)).isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void addSkillShouldRejectDuplicateName() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+ SkillMetadata metadata1 = SkillMetadata.builder("test", "description1", "source").build();
+ SkillMetadata metadata2 = SkillMetadata.builder("test", "description2", "source").build();
+
+ skillBox.addSkill("test", metadata1);
+
+ assertThatThrownBy(() -> skillBox.addSkill("test", metadata2)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("already exists");
+ }
+
+ @Test
+ void activationShouldToggleState() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+ SkillMetadata metadata = SkillMetadata.builder("test", "description", "source").build();
+ skillBox.addSkill("test", metadata);
+
+ skillBox.activateSkill("test");
+ assertThat(skillBox.isActivated("test")).isTrue();
+ assertThat(skillBox.getActivatedSkillNames()).containsExactly("test");
+
+ skillBox.deactivateSkill("test");
+ assertThat(skillBox.isActivated("test")).isFalse();
+ assertThat(skillBox.getActivatedSkillNames()).isEmpty();
+ }
+
+ @Test
+ void deactivateAllSkillsShouldClearAllActivation() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+ SkillMetadata metadata1 = SkillMetadata.builder("skill1", "description", "source").build();
+ SkillMetadata metadata2 = SkillMetadata.builder("skill2", "description", "source").build();
+ skillBox.addSkill("skill1", metadata1);
+ skillBox.addSkill("skill2", metadata2);
+ skillBox.activateSkill("skill1");
+ skillBox.activateSkill("skill2");
+
+ skillBox.deactivateAllSkills();
+
+ assertThat(skillBox.isActivated("skill1")).isFalse();
+ assertThat(skillBox.isActivated("skill2")).isFalse();
+ assertThat(skillBox.getActivatedSkillNames()).isEmpty();
+ }
+
+ @Test
+ void isActivatedShouldReturnFalseForNonexistentSkill() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+
+ assertThat(skillBox.isActivated("nonexistent")).isFalse();
+ }
+
+ @Test
+ void sourceManagementShouldWorkCorrectly() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+
+ assertThat(skillBox.getSources()).containsExactly("custom");
+
+ skillBox.setSources(List.of("source1", "source2"));
+ assertThat(skillBox.getSources()).containsExactly("source1", "source2");
+
+ skillBox.addSource("source3");
+ assertThat(skillBox.getSources()).contains("source1", "source2", "source3");
+ }
+
+ @Test
+ void getActivatedSkillNamesShouldReturnOnlyActiveSkills() {
+ SimpleSkillBox skillBox = new SimpleSkillBox();
+ SkillMetadata metadata1 = SkillMetadata.builder("skill1", "description", "source").build();
+ SkillMetadata metadata2 = SkillMetadata.builder("skill2", "description", "source").build();
+ SkillMetadata metadata3 = SkillMetadata.builder("skill3", "description", "source").build();
+ skillBox.addSkill("skill1", metadata1);
+ skillBox.addSkill("skill2", metadata2);
+ skillBox.addSkill("skill3", metadata3);
+ skillBox.activateSkill("skill1");
+ skillBox.activateSkill("skill3");
+
+ assertThat(skillBox.getActivatedSkillNames()).containsExactlyInAnyOrder("skill1", "skill3");
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/SkillMetadataTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SkillMetadataTests.java
new file mode 100644
index 00000000000..758abe6caeb
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SkillMetadataTests.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.skill.core.SkillMetadata;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link SkillMetadata}.
+ *
+ * @author LinPeng Zhang
+ */
+class SkillMetadataTests {
+
+ @Test
+ void builderShouldCreateMetadataWithRequiredFields() {
+ SkillMetadata metadata = SkillMetadata.builder("test-skill", "Test description", "spring").build();
+
+ assertThat(metadata.getName()).isEqualTo("test-skill");
+ assertThat(metadata.getDescription()).isEqualTo("Test description");
+ assertThat(metadata.getSource()).isEqualTo("spring");
+ assertThat(metadata.getExtensions()).isEmpty();
+ }
+
+ @Test
+ void builderShouldStoreExtensions() {
+ SkillMetadata metadata = SkillMetadata.builder("skill", "description", "spring")
+ .extension("version", "1.0.0")
+ .extension("author", "test")
+ .build();
+
+ assertThat(metadata.getExtension("version")).isEqualTo("1.0.0");
+ assertThat(metadata.getExtension("author")).isEqualTo("test");
+ assertThat(metadata.getExtension("nonexistent")).isNull();
+ }
+
+ @Test
+ void builderShouldValidateRequiredFields() {
+ assertThatThrownBy(() -> SkillMetadata.builder(null, "description", "spring").build())
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> SkillMetadata.builder("", "description", "spring").build())
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> SkillMetadata.builder("name", null, "spring").build())
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> SkillMetadata.builder("name", "", "spring").build())
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> SkillMetadata.builder("name", "description", null).build())
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> SkillMetadata.builder("name", "description", "").build())
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void equalsShouldWorkCorrectly() {
+ SkillMetadata metadata1 = SkillMetadata.builder("name", "description", "spring")
+ .extension("key", "value")
+ .build();
+ SkillMetadata metadata2 = SkillMetadata.builder("name", "description", "spring")
+ .extension("key", "value")
+ .build();
+ SkillMetadata metadata3 = SkillMetadata.builder("different", "description", "spring").build();
+
+ assertThat(metadata1).isEqualTo(metadata2).hasSameHashCodeAs(metadata2);
+ assertThat(metadata1).isNotEqualTo(metadata3);
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/CalculatorSkill.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/CalculatorSkill.java
new file mode 100644
index 00000000000..1b9a32375da
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/CalculatorSkill.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.fixtures;
+
+import org.springframework.ai.skill.annotation.Skill;
+import org.springframework.ai.skill.annotation.SkillContent;
+
+/**
+ * Test Calculator Skill
+ *
+ *
+ * Simple POJO Skill for testing framework functionality
+ *
+ * @author Semir
+ */
+@Skill(name = "calculator", description = "A simple calculator skill for basic arithmetic operations",
+ source = "example", extensions = { "version=1.0.0", "author=Semir" })
+public class CalculatorSkill {
+
+ public CalculatorSkill() {
+ }
+
+ @SkillContent
+ public String content() {
+ return """
+ # Calculator Skill
+
+ A simple calculator skill that provides basic arithmetic operations.
+
+ ## Features
+
+ - Addition
+ - Subtraction
+ - Multiplication
+ - Division
+
+ ## Usage
+
+ Use this skill to perform basic calculations.
+ """;
+ }
+
+}
diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/WeatherSkill.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/WeatherSkill.java
new file mode 100644
index 00000000000..92f121768dc
--- /dev/null
+++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/WeatherSkill.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.skill.fixtures;
+
+import java.util.List;
+
+import org.springframework.ai.skill.annotation.Skill;
+import org.springframework.ai.skill.annotation.SkillContent;
+import org.springframework.ai.skill.annotation.SkillInit;
+import org.springframework.ai.skill.annotation.SkillTools;
+import org.springframework.ai.support.ToolCallbacks;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.annotation.Tool;
+
+/**
+ * Test Weather Skill POJO
+ *
+ *
+ * Weather skill using fixed data for testing
+ *
+ * @author Semir
+ */
+@Skill(name = "weather", description = "Provides weather information for cities around the world", source = "example",
+ extensions = { "version=1.0.0", "author=Semir", "category=information" })
+public class WeatherSkill {
+
+ private WeatherSkill() {
+ }
+
+ @SkillInit
+ public static WeatherSkill create() {
+ return new WeatherSkill();
+ }
+
+ @SkillContent
+ public String content() {
+ return """
+ # Weather Skill
+
+ Provides weather information for cities around the world.
+
+ ## Features
+
+ - Get current weather for any city
+ - Temperature in Celsius
+ - Weather conditions (Sunny, Rainy, Cloudy, etc.)
+
+ ## Available Tools
+
+ - `getWeather(city)` - Get current weather for a specific city
+
+ ## Usage
+
+ Ask me "What's the weather in Beijing?" or "Tell me the weather in New York".
+ """;
+ }
+
+ @SkillTools
+ public List tools() {
+ return List.of(ToolCallbacks.from(this));
+ }
+
+ /**
+ * Get weather information for a specific city (uses fixed data for testing)
+ * @param city city name
+ * @return JSON string containing weather information
+ */
+ @Tool(description = "Get current weather information for a specific city. Returns temperature in Celsius, weather condition, humidity percentage, and wind speed in km/h.")
+ public String getWeather(String city) {
+ if (city == null || city.trim().isEmpty()) {
+ return "{\"error\": \"City name is required\"}";
+ }
+
+ // Use fixed data for test verification
+ int temperature;
+ String condition;
+ int humidity;
+ int windSpeed;
+
+ switch (city.toLowerCase()) {
+ case "beijing":
+ temperature = 25;
+ condition = "Sunny";
+ humidity = 60;
+ windSpeed = 15;
+ break;
+ case "new york":
+ temperature = 20;
+ condition = "Cloudy";
+ humidity = 70;
+ windSpeed = 20;
+ break;
+ case "london":
+ temperature = 15;
+ condition = "Rainy";
+ humidity = 85;
+ windSpeed = 25;
+ break;
+ case "tokyo":
+ temperature = 22;
+ condition = "Partly Cloudy";
+ humidity = 65;
+ windSpeed = 10;
+ break;
+ case "paris":
+ temperature = 18;
+ condition = "Foggy";
+ humidity = 75;
+ windSpeed = 12;
+ break;
+ default:
+ temperature = 20;
+ condition = "Clear";
+ humidity = 50;
+ windSpeed = 10;
+ break;
+ }
+
+ return String.format(
+ "{\"city\": \"%s\", \"temperature\": %d, \"unit\": \"Celsius\", \"condition\": \"%s\", "
+ + "\"humidity\": %d, \"windSpeed\": %d, \"windUnit\": \"km/h\", "
+ + "\"description\": \"The weather in %s is %s with a temperature of %d°C, "
+ + "humidity at %d%%, and wind speed of %d km/h.\"}",
+ city, temperature, condition, humidity, windSpeed, city, condition, temperature, humidity, windSpeed);
+ }
+
+}
diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml
index f277367b0dd..6551ce6d930 100644
--- a/src/checkstyle/checkstyle-suppressions.xml
+++ b/src/checkstyle/checkstyle-suppressions.xml
@@ -97,5 +97,7 @@
+
+