You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/09/28 13:50:24 UTC

(grails-core) 01/01: Publish app-facing agent skills as SkillsJars jars

This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch docs/publish-grails-skillsjars
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit caf108c68419ca6595890c10e2b4cbd1e4dc5fa4
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Thu Sep 24 11:11:13 2026 -0400

    Publish app-facing agent skills as SkillsJars jars
    
    Package the grails-developer and grails-8-upgrade skills into
    org.apache.grails.skills jars that are built, signed, and voted on with
    the rest of each Grails release, rather than submitting the repository to
    the SkillsJars publisher, which deploys unvoted com.skillsjars artifacts
    outside the ASF release process.
    
    Each skill now lives in the project that publishes it, under
    grails-skills/<project>/skills/<skill>/. The .agents/skills entries for
    these skills become symlinks to those directories, so agents such as
    OpenCode still find them, and any files they ship beside SKILL.md, where
    they look by default. The new
    org.apache.grails.buildsrc.agent-skills convention plugin packages a
    project's skills/ directories at META-INF/skills/<githubSlug>/<skill>/,
    the SkillsJars layout, and fails the build when a project has no skills, a
    skill has no SKILL.md, or a skill's frontmatter has no description or a
    name that differs from its directory. The Grails BOM manages the skills.
    
    Applications extract them with the SkillsJars Gradle plugin 0.1.3, which
    extracts skill jars from any group and no longer fails projects without a
    skills/ directory. The Grails Gradle plugin applies the Grails BOM to its
    skill configuration, so an application declares the skills without a
    version; a Grails 7 application names it. The guide warns that extraction
    empties the output directory.
    
    end-to-end/agent-skills and end-to-end/agent-skills-plain-build are example
    builds that resolve the published jars and BOM through the SkillsJars
    plugin and check what they extract.
    
    Refs #15454
    
    Co-authored-by: James Fredley <ja...@users.noreply.github.com>
---
 .agents/skills/README.md                           |  40 ++++
 .agents/skills/gradle-developer/SKILL.md           |   1 +
 .agents/skills/grails-8-upgrade                    |   1 +
 .agents/skills/grails-developer                    |   1 +
 .github/dependency-graph/external-references.yml   |   2 +
 AGENTS.md                                          |   2 +-
 build-logic/plugins/build.gradle                   |   4 +
 .../grails/buildsrc/AgentSkillsPlugin.groovy       | 140 ++++++++++++++
 .../grails/buildsrc/AgentSkillsPluginSpec.groovy   | 209 +++++++++++++++++++++
 build.gradle                                       |   5 +
 end-to-end/README.md                               |   2 +
 end-to-end/agent-skills-plain-build/.gitignore     |   2 +
 end-to-end/agent-skills-plain-build/build.gradle   |  38 ++++
 end-to-end/agent-skills/.gitignore                 |   2 +
 end-to-end/agent-skills/build.gradle               |  73 +++++++
 .../grails/e2e/skills/AgentSkillsSpec.groovy       | 105 +++++++++++
 end-to-end/settings.gradle                         |   4 +
 gradle/publish-root-config.gradle                  |   2 +-
 gradle/rat-root-config.gradle                      |   1 +
 .../src/en/guide/gettingStarted/agentSkills.adoc   | 100 ++++++++++
 grails-doc/src/en/guide/introduction/whatsNew.adoc |   4 +
 grails-doc/src/en/guide/toc.yml                    |   1 +
 .../src/en/guide/upgrading/upgrading80x.adoc       |  14 ++
 grails-skills/developer/build.gradle               |  33 ++++
 .../developer}/skills/grails-developer/SKILL.md    |  12 +-
 grails-skills/upgrade-guide-8/build.gradle         |  33 ++++
 .../skills/grails-8-upgrade/SKILL.md               |  32 ++--
 settings.gradle                                    |   7 +
 28 files changed, 848 insertions(+), 22 deletions(-)

diff --git a/.agents/skills/README.md b/.agents/skills/README.md
new file mode 100644
index 0000000000..a1d3b27619
--- /dev/null
+++ b/.agents/skills/README.md
@@ -0,0 +1,40 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+
+Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements; and to You under the Apache License, Version 2.0.
+-->
+
+# Grails Agent Skills
+
+Agents working in this repository find the Grails agent skills in this directory. Skills that are only useful while working in the Grails framework repository live here and are not published.
+
+App-facing skills, useful when building or upgrading Grails applications, are also published as jars with every Grails release. Their canonical source lives in the Gradle project that publishes each one, under `grails-skills/<project>/skills/<skill>/`. The `.agents/skills/<skill>` entries for them are symbolic links to those directories, so agents such as OpenCode still find them, with any files they ship beside `SKILL.md`, where they look by default, and `.claude/skills` links to those  [...]
+
+| Published skill | Gradle project | Maven coordinates | Use |
+|-----------------|----------------|-------------------|-----|
+| `grails-developer` | `:grails-skills-developer` (`grails-skills/developer/skills/grails-developer`) | `org.apache.grails.skills:grails-developer` | Building Grails web applications, REST APIs, GORM models, controllers, services, views, plugins, and tests |
+| `grails-8-upgrade` | `:grails-skills-upgrade-guide-8` (`grails-skills/upgrade-guide-8/skills/grails-8-upgrade`) | `org.apache.grails.skills:grails-8-upgrade` | Upgrading Grails applications from Grails 7.x to Grails 8 |
+
+The jars use the [SkillsJars](https://www.skillsjars.com/) layout, holding the skill directory at `META-INF/skills/apache/grails-core/<skill>/`, so SkillsJars tooling extracts them the same way it would a SkillsJars catalog jar of this repository. They are released, signed, and voted on with the rest of Grails, and the Grails BOM manages their versions, so an application declares them without a version. Applications extract them with the SkillsJars Gradle plugin, as the Agent Skills sect [...]
+
+The `gradle-developer`, `groovy-developer`, `hibernate-developer`, `java-developer`, `mono-repo-integration`, `test-fixer`, and `violation-fixer` skills are intentionally not published because they are for Grails framework development. Contributors already receive them from this repository when working on Grails core.
+
+To publish another skill:
+
+1. Create a Gradle project under `grails-skills/<project>/` whose `build.gradle` matches the existing ones: it applies `org.apache.grails.buildsrc.agent-skills` and sets `pomArtifactId` to the skill's name, plus `pomTitle` and `pomDescription`.
+2. Put the skill in `grails-skills/<project>/skills/<skill>/`: a `SKILL.md` whose frontmatter `name` is `<skill>`, and any files it ships beside it. The plugin packages that directory at `META-INF/skills/apache/grails-core/<skill>/` and fails the build if the name and directory differ.
+3. Link `.agents/skills/<skill>` to that directory, so agents working in this repository find it:
+   ```bash
+   ln -s ../../grails-skills/<project>/skills/<skill> .agents/skills/<skill>
+   ```
+4. Register the project in `settings.gradle` as `grails-skills-<name>`. The root build collects every `grails-skills-*` project into `skillProjects` and publishes it, which also puts it in the Grails BOM.
+5. Add its coordinates to `.github/dependency-graph/external-references.yml` and to the table in `grails-doc/src/en/guide/gettingStarted/agentSkills.adoc`, and declare it in the example builds `end-to-end/agent-skills` and `end-to-end/agent-skills-plain-build`, adding it and its directory to `PUBLISHED_SKILLS` in their spec.
+
+Inspect a skill jar locally with:
+
+```bash
+./gradlew :grails-skills-developer:jar
+unzip -l grails-skills/developer/build/libs/grails-skills-developer-*.jar
+```
+
+`end-to-end/agent-skills` is an example application that gets the published skills the way the user guide shows, and `end-to-end/agent-skills-plain-build` does the same from a build without the Grails Gradle plugin; see `end-to-end/README.md` for how to run them.
diff --git a/.agents/skills/gradle-developer/SKILL.md b/.agents/skills/gradle-developer/SKILL.md
index 98a092170c..75129f010f 100644
--- a/.agents/skills/gradle-developer/SKILL.md
+++ b/.agents/skills/gradle-developer/SKILL.md
@@ -380,6 +380,7 @@ Plugin IDs (implementation under `build-logic/plugins/…/buildsrc/`):
 | `org.apache.grails.buildsrc.groovydoc` | Groovydoc |
 | `org.apache.grails.buildsrc.groovydoc-enhancer` | Groovydoc enhancer |
 | `org.apache.grails.buildsrc.repo` | Settings plugin: Apache snapshot/staging repo content filters |
+| `org.apache.grails.buildsrc.agent-skills` | Packages `skills/<skill>/` into the jar at `META-INF/skills/apache/grails-core/<skill>/` (SkillsJars layout); validates each skill's frontmatter `name` |
 | `org.apache.grails.gradle.grails-code-style` | Checkstyle + CodeNarc |
 | `org.apache.grails.gradle.grails-code-analysis` | PMD + SpotBugs (opt-in props) |
 | `org.apache.grails.gradle.grails-jacoco` | JaCoCo per project |
diff --git a/.agents/skills/grails-8-upgrade b/.agents/skills/grails-8-upgrade
new file mode 120000
index 0000000000..a413a05528
--- /dev/null
+++ b/.agents/skills/grails-8-upgrade
@@ -0,0 +1 @@
+../../grails-skills/upgrade-guide-8/skills/grails-8-upgrade
\ No newline at end of file
diff --git a/.agents/skills/grails-developer b/.agents/skills/grails-developer
new file mode 120000
index 0000000000..374f035c75
--- /dev/null
+++ b/.agents/skills/grails-developer
@@ -0,0 +1 @@
+../../grails-skills/developer/skills/grails-developer
\ No newline at end of file
diff --git a/.github/dependency-graph/external-references.yml b/.github/dependency-graph/external-references.yml
index 1ef85dacc5..14e1816d73 100644
--- a/.github/dependency-graph/external-references.yml
+++ b/.github/dependency-graph/external-references.yml
@@ -97,6 +97,8 @@ references:
   - purl: pkg:maven/org.apache.grails.events/grails-events-spring
   - purl: pkg:maven/org.apache.grails.events/grails-events-transforms
   - purl: pkg:maven/org.apache.grails.i18n/grails-i18n
+  - purl: pkg:maven/org.apache.grails.skills/grails-8-upgrade
+  - purl: pkg:maven/org.apache.grails.skills/grails-developer
   - purl: pkg:maven/org.apache.grails.views/grails-gsp-core
   - purl: pkg:maven/org.apache.grails.views/grails-views-core
   - purl: pkg:maven/org.apache.grails.views/grails-views-gson
diff --git a/AGENTS.md b/AGENTS.md
index 4fb8771ceb..60662b2361 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,7 +73,7 @@ export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx4G"
 
 | Skill | Path | Use For |
 |-------|------|---------|
-| **grails-developer** | `.agents/skills/grails-developer/SKILL.md` | Current Grails apps, GORM, controllers, views |
+| **grails-developer** | `.agents/skills/grails-developer/SKILL.md` | Grails apps, GORM, controllers, views |
 | **groovy-developer** | `.agents/skills/groovy-developer/SKILL.md` | Groovy 5 syntax, closures, DSLs, Spock |
 | **gradle-developer** | `.agents/skills/gradle-developer/SKILL.md` | Gradle 9 builds, BOM/platforms, convention plugins, wrappers |
 | **grails-8-upgrade** | `.agents/skills/grails-8-upgrade/SKILL.md` | Upgrading Grails applications from 7.x to 8 |
diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle
index 81b6731bef..812b7c607b 100644
--- a/build-logic/plugins/build.gradle
+++ b/build-logic/plugins/build.gradle
@@ -134,5 +134,9 @@ gradlePlugin {
             id = 'org.apache.grails.buildsrc.test-task-sharding'
             implementationClass = 'org.apache.grails.buildsrc.TestTaskShardingPlugin'
         }
+        register('agentSkillsPlugin') {
+            id = 'org.apache.grails.buildsrc.agent-skills'
+            implementationClass = 'org.apache.grails.buildsrc.AgentSkillsPlugin'
+        }
     }
 }
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/AgentSkillsPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/AgentSkillsPlugin.groovy
new file mode 100644
index 0000000000..216c8e0ad3
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/AgentSkillsPlugin.groovy
@@ -0,0 +1,140 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.InvalidUserDataException
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.file.CopySpec
+import org.gradle.api.file.Directory
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.Sync
+import org.gradle.api.tasks.TaskProvider
+
+import static org.apache.grails.buildsrc.GradleUtils.lookupProperty
+
+/**
+ * Packages the agent skills of a project into its jar with the SkillsJars layout.
+ *
+ * <p>Each directory under {@code skills/} is one skill: a {@code SKILL.md} whose frontmatter
+ * {@code name} is the directory name, and any files it ships beside it. The skills are packaged at
+ * {@code META-INF/skills/<githubSlug>/<skill>/}, where {@code githubSlug} is the same
+ * {@code <org>/<repo>} the publish plugin writes into the POM, so SkillsJars tooling extracts the
+ * jar the same way it would a SkillsJars catalog jar of that repository.
+ */
+@CompileStatic
+class AgentSkillsPlugin implements Plugin<Project> {
+
+    static final String SKILLS_DIRECTORY = 'skills'
+    static final String SKILLS_RESOURCE_ROOT = 'META-INF/skills'
+    static final String SKILL_FILE = 'SKILL.md'
+    static final String DEFAULT_GITHUB_SLUG = 'apache/grails-core'
+
+    @Override
+    void apply(Project project) {
+        project.plugins.withId('java') {
+            configureSkillResources(project)
+        }
+    }
+
+    private static void configureSkillResources(Project project) {
+        Directory skillsDirectory = project.layout.projectDirectory.dir(SKILLS_DIRECTORY)
+        String projectPath = project.path
+        // Checked while configuring: with nothing to copy the task below is skipped as NO-SOURCE,
+        // and the project would publish an empty jar.
+        if (!skillDirectories(skillsDirectory.asFile)) {
+            throw new InvalidUserDataException(
+                    "${projectPath} applies the agent skills plugin but has no skills: expected ${SKILLS_DIRECTORY}/<skill>/${SKILL_FILE}"
+            )
+        }
+
+        TaskProvider<Sync> skillResources = project.tasks.register('agentSkillResources', Sync) { Sync task ->
+            task.group = 'build'
+            task.description = "Packages the agent skills under ${SKILLS_DIRECTORY}/ into ${SKILLS_RESOURCE_ROOT}/."
+            task.from(skillsDirectory) { CopySpec spec ->
+                spec.into(project.provider {
+                    "${SKILLS_RESOURCE_ROOT}/${lookupProperty(project, 'githubSlug', DEFAULT_GITHUB_SLUG)}" as String
+                })
+            }
+            task.into(project.layout.buildDirectory.dir('generated/agent-skills'))
+            task.doFirst {
+                validateSkills(projectPath, skillsDirectory.asFile)
+            }
+        }
+
+        project.extensions.getByType(SourceSetContainer).named(SourceSet.MAIN_SOURCE_SET_NAME) { SourceSet sourceSet ->
+            sourceSet.resources.srcDir(skillResources)
+        }
+    }
+
+    /**
+     * A skill agents cannot load must not be published: every directory under {@code skills/} needs a
+     * {@code SKILL.md} whose frontmatter names the directory, which is how agents identify it, and
+     * describes the skill, which is how agents decide when to load it.
+     */
+    private static void validateSkills(String projectPath, File skillsDirectory) {
+        skillDirectories(skillsDirectory).each { File skill ->
+            File skillFile = new File(skill, SKILL_FILE)
+            if (!skillFile.file) {
+                throw new InvalidUserDataException("Agent skill '${skill.name}' of ${projectPath} has no ${SKILL_FILE}")
+            }
+            String declaredName = frontmatterValue(skillFile, 'name')
+            if (!declaredName) {
+                throw new InvalidUserDataException(
+                        "Agent skill '${skill.name}' of ${projectPath} has no frontmatter name in ${SKILL_FILE}"
+                )
+            }
+            if (declaredName != skill.name) {
+                throw new InvalidUserDataException(
+                        "Agent skill '${skill.name}' of ${projectPath} declares the name '${declaredName}' in ${SKILL_FILE}; " +
+                                'the frontmatter name must match its directory'
+                )
+            }
+            if (!frontmatterValue(skillFile, 'description')) {
+                throw new InvalidUserDataException(
+                        "Agent skill '${skill.name}' of ${projectPath} has no frontmatter description in ${SKILL_FILE}"
+                )
+            }
+        }
+    }
+
+    private static List<File> skillDirectories(File skillsDirectory) {
+        (skillsDirectory.listFiles()?.findAll { File file -> file.directory } ?: []) as List<File>
+    }
+
+    private static String frontmatterValue(File skillFile, String key) {
+        List<String> lines = skillFile.readLines('UTF-8')
+        if (!lines || lines.first().trim() != '---') {
+            return null
+        }
+        String prefix = "${key}:"
+        for (String line : lines.drop(1)) {
+            if (line.trim() == '---') {
+                return null
+            }
+            if (line.startsWith(prefix)) {
+                return line.substring(prefix.length()).trim()
+            }
+        }
+        null
+    }
+}
diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/AgentSkillsPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/AgentSkillsPluginSpec.groovy
new file mode 100644
index 0000000000..6b979cf489
--- /dev/null
+++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/AgentSkillsPluginSpec.groovy
@@ -0,0 +1,209 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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.apache.grails.buildsrc
+
+import java.util.zip.ZipFile
+
+import org.gradle.api.InvalidUserDataException
+import org.gradle.api.Project
+import org.gradle.testfixtures.ProjectBuilder
+import org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.GradleRunner
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class AgentSkillsPluginSpec extends Specification {
+
+    @TempDir
+    File projectDir
+
+    def setup() {
+        new File(projectDir, 'settings.gradle').text = "rootProject.name = 'skills-fixture'"
+        writeBuild('')
+    }
+
+    void 'each skill and the files beside it are packaged under the SkillsJars path of the repository'() {
+        given:
+        writeSkill('first-skill')
+        writeSkill('second-skill')
+        writeFile('skills/second-skill/references/detail.md', 'detail')
+
+        when:
+        build('jar')
+
+        then:
+        skillEntries() == [
+                'META-INF/skills/apache/grails-core/first-skill/SKILL.md',
+                'META-INF/skills/apache/grails-core/second-skill/SKILL.md',
+                'META-INF/skills/apache/grails-core/second-skill/references/detail.md',
+        ]
+
+        and: 'unchanged'
+        jarText('META-INF/skills/apache/grails-core/first-skill/SKILL.md') ==
+                new File(projectDir, 'skills/first-skill/SKILL.md').text
+    }
+
+    void 'the skills are packaged under the githubSlug the project sets'() {
+        given: 'the slug set after the plugin is applied, as a build script sets it'
+        writeBuild("ext.githubSlug = 'example/other-repo'")
+        writeSkill('first-skill')
+
+        when:
+        build('jar')
+
+        then:
+        skillEntries() == ['META-INF/skills/example/other-repo/first-skill/SKILL.md']
+    }
+
+    void 'a skill removed from the project is gone from the next jar'() {
+        given:
+        writeSkill('first-skill')
+        writeSkill('second-skill')
+        build('jar')
+
+        when:
+        new File(projectDir, 'skills/second-skill').deleteDir()
+        build('jar')
+
+        then:
+        skillEntries() == ['META-INF/skills/apache/grails-core/first-skill/SKILL.md']
+    }
+
+    void 'a skill whose frontmatter name does not match its directory fails the build'() {
+        given:
+        writeSkill('first-skill', 'another-name')
+
+        when:
+        BuildResult result = buildAndFail('jar')
+
+        then:
+        result.output.contains("Agent skill 'first-skill' of : declares the name 'another-name' in SKILL.md")
+        result.output.contains(InvalidUserDataException.name)
+    }
+
+    void 'a skill whose SKILL.md #problem fails the build'() {
+        given:
+        writeFile('skills/first-skill/SKILL.md', text)
+
+        when:
+        BuildResult result = buildAndFail('jar')
+
+        then:
+        result.output.contains("Agent skill 'first-skill' of : ${message}")
+        result.output.contains(InvalidUserDataException.name)
+
+        where:
+        problem                          | text                                                          || message
+        'has no frontmatter'             | '# No frontmatter\n'                                          || 'has no frontmatter name in SKILL.md'
+        'has frontmatter without a name' | '---\ndescription: A skill\n---\n'                            || 'has no frontmatter name in SKILL.md'
+        'has no description'             | '---\nname: first-skill\n---\n'                               || 'has no frontmatter description in SKILL.md'
+        'has a blank description'        | '---\nname: first-skill\ndescription:   \n---\n'              || 'has no frontmatter description in SKILL.md'
+        'describes it after frontmatter' | '---\nname: first-skill\n---\ndescription: not frontmatter\n' || 'has no frontmatter description in SKILL.md'
+    }
+
+    void 'a skill directory without a SKILL.md fails the build'() {
+        given:
+        writeFile('skills/first-skill/README.md', 'not a skill')
+
+        when:
+        BuildResult result = buildAndFail('jar')
+
+        then:
+        result.output.contains("Agent skill 'first-skill' of : has no SKILL.md")
+        result.output.contains(InvalidUserDataException.name)
+    }
+
+    void 'a project with no skills fails the build rather than publish an empty jar'() {
+        when:
+        BuildResult result = buildAndFail('jar')
+
+        then:
+        result.output.contains(': applies the agent skills plugin but has no skills: expected skills/<skill>/SKILL.md')
+        result.output.contains(InvalidUserDataException.name)
+    }
+
+    void 'nothing is registered for a project without the java plugin'() {
+        given:
+        Project project = ProjectBuilder.builder().withProjectDir(projectDir).build()
+
+        when:
+        project.pluginManager.apply(AgentSkillsPlugin)
+
+        then:
+        project.tasks.findByName('agentSkillResources') == null
+    }
+
+    private void writeBuild(String extra) {
+        new File(projectDir, 'build.gradle').text = """
+            plugins {
+                id 'java'
+                id 'org.apache.grails.buildsrc.agent-skills'
+            }
+
+            ${extra}
+        """
+    }
+
+    private void writeSkill(String directory, String name = directory) {
+        writeFile("skills/${directory}/SKILL.md", """---
+name: ${name}
+description: A skill for the fixture
+---
+
+Instructions for ${name}.
+""")
+    }
+
+    private void writeFile(String path, String text) {
+        File file = new File(projectDir, path)
+        file.parentFile.mkdirs()
+        file.text = text
+    }
+
+    private List<String> skillEntries() {
+        new ZipFile(jarFile()).withCloseable { ZipFile zip ->
+            zip.entries().collect { it.name }.findAll { it.startsWith('META-INF/skills/') && !it.endsWith('/') }.sort()
+        }
+    }
+
+    private String jarText(String entry) {
+        new ZipFile(jarFile()).withCloseable { ZipFile zip ->
+            zip.getInputStream(zip.getEntry(entry)).getText('UTF-8')
+        }
+    }
+
+    private File jarFile() {
+        new File(projectDir, 'build/libs/skills-fixture.jar')
+    }
+
+    private BuildResult build(String task) {
+        runner(task).build()
+    }
+
+    private BuildResult buildAndFail(String task) {
+        runner(task).buildAndFail()
+    }
+
+    private GradleRunner runner(String task) {
+        GradleRunner.create()
+                .withProjectDir(projectDir)
+                .withArguments(task, '--stacktrace')
+                .withPluginClasspath()
+    }
+}
diff --git a/build.gradle b/build.gradle
index 61f25361f5..e5a772e7df 100644
--- a/build.gradle
+++ b/build.gradle
@@ -65,6 +65,7 @@ ext {
     docProjects = [ /* Will be populated by subprojects loop below */]
     cliProjects = [ /* Will be populated by subprojects loop below */]
     profileProjects = [ /* Will be populated by subprojects loop below */]
+    skillProjects = [ /* Will be populated by subprojects loop below */]
 }
 
 /**
@@ -139,6 +140,10 @@ subprojects {
         rootProject.ext['profileProjects'] << name
     }
 
+    if (name.startsWith('grails-skills')) {
+        rootProject.ext['skillProjects'] << name
+    }
+
     configurations.configureEach {
         resolutionStrategy {
             def cacheHours = isCiBuild || isReproducibleBuild ? 0 : 24
diff --git a/end-to-end/README.md b/end-to-end/README.md
index 7d2dc6cfb6..b378f89f04 100644
--- a/end-to-end/README.md
+++ b/end-to-end/README.md
@@ -32,6 +32,8 @@ applications at via `GRAILS_REPO_URL`.
 
 | Project | What it is |
 |---|---|
+| `agent-skills` | An example Grails 8 application that gets the published agent skills the way the user guide shows: extracted by the SkillsJars Gradle plugin, declared without a version and resolved through the Grails BOM. Its test checks what it and `agent-skills-plain-build` extract. |
+| `agent-skills-plain-build` | The same from a build without the Grails Gradle plugin, which names the Grails BOM on the `skill` configuration itself. |
 | `legacy-g7-command-plugin` | A **standalone build**, not part of this one. Compiles against published Grails 7 / Groovy 4 to produce a genuine precompiled `grails.dev.commands.ApplicationCommand` binary. |
 | `legacy-commands-plugin` | A Grails 8 plugin whose legacy commands are recompiled under Groovy 5. |
 | `legacy-commands` | A Grails 8 application that consumes both and runs their commands through the registry. |
diff --git a/end-to-end/agent-skills-plain-build/.gitignore b/end-to-end/agent-skills-plain-build/.gitignore
new file mode 100644
index 0000000000..50bb7f7608
--- /dev/null
+++ b/end-to-end/agent-skills-plain-build/.gitignore
@@ -0,0 +1,2 @@
+# Skills extracted by extractSkillsJars are build output
+/.agents/
diff --git a/end-to-end/agent-skills-plain-build/build.gradle b/end-to-end/agent-skills-plain-build/build.gradle
new file mode 100644
index 0000000000..7848f54cc3
--- /dev/null
+++ b/end-to-end/agent-skills-plain-build/build.gradle
@@ -0,0 +1,38 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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.
+ */
+
+// A build without the Grails Gradle plugin - the root of a multi-project build whose Grails
+// applications are subprojects, for example - gets the Grails agent skills the way the user guide's
+// Agent Skills section describes: it applies the SkillsJars Gradle plugin and names the Grails BOM on
+// the skill configuration itself, since nothing applies the BOM for it. agent-skills checks what it
+// extracts.
+plugins {
+    id 'com.skillsjars.gradle-plugin' version '0.1.3'
+}
+
+dependencies {
+    skill platform("org.apache.grails:grails-bom:${projectVersion}")
+    skill 'org.apache.grails.skills:grails-developer'
+    skill 'org.apache.grails.skills:grails-8-upgrade'
+}
+
+// As in agent-skills: this project's own directory, not the grails-core repository's .agents/skills
+skillsjars {
+    outputDir = layout.projectDirectory.dir('.agents/skills')
+}
diff --git a/end-to-end/agent-skills/.gitignore b/end-to-end/agent-skills/.gitignore
new file mode 100644
index 0000000000..50bb7f7608
--- /dev/null
+++ b/end-to-end/agent-skills/.gitignore
@@ -0,0 +1,2 @@
+# Skills extracted by extractSkillsJars are build output
+/.agents/
diff --git a/end-to-end/agent-skills/build.gradle b/end-to-end/agent-skills/build.gradle
new file mode 100644
index 0000000000..1b595cd275
--- /dev/null
+++ b/end-to-end/agent-skills/build.gradle
@@ -0,0 +1,73 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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.
+ */
+
+// A Grails application that gets the Grails agent skills the way the user guide's Agent Skills
+// section describes. It applies the SkillsJars Gradle plugin, which adds the skill configuration and
+// the extractSkillsJars task, and declares the skills without a version: the Grails Gradle plugin
+// applies the Grails BOM to the skill configuration as it does to the others. Only the build takes
+// part, so the application has no sources of its own.
+//
+// It lives in the end-to-end build because only published artifacts show what an application gets:
+// the published skill jars, their metadata, and the published BOM.
+plugins {
+    id 'groovy'
+    // Supplies grailsVersion, which an application sets in its gradle.properties
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.gradle.grails-web'
+    id 'com.skillsjars.gradle-plugin' version '0.1.3'
+}
+
+version = '0.1'
+group = 'agentskills'
+
+dependencies {
+    skill 'org.apache.grails.skills:grails-developer'
+    skill 'org.apache.grails.skills:grails-8-upgrade'
+}
+
+// extractSkillsJars replaces everything in this directory. An application at the root of its
+// repository points it at the .agents/skills that agents read; this project's own directory keeps
+// it away from the grails-core repository's .agents/skills.
+skillsjars {
+    outputDir = layout.projectDirectory.dir('.agents/skills')
+}
+
+// Everything below checks the skills this project and agent-skills-plain-build extract; it is not
+// part of the example.
+dependencies {
+    testImplementation 'org.spockframework:spock-core'
+    testImplementation 'org.apache.groovy:groovy'
+    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test', Test) {
+    useJUnitPlatform()
+    // extractSkillsJars declares no outputs; depend on it directly and read what it wrote as inputs
+    dependsOn('extractSkillsJars', ':agent-skills-plain-build:extractSkillsJars')
+    inputs.dir(layout.projectDirectory.dir('.agents/skills'))
+            .withPropertyName('extractedSkills')
+            .withPathSensitivity(PathSensitivity.RELATIVE)
+    inputs.dir(rootProject.layout.projectDirectory.dir('agent-skills-plain-build/.agents/skills'))
+            .withPropertyName('plainBuildExtractedSkills')
+            .withPathSensitivity(PathSensitivity.RELATIVE)
+    // What the published jars were packaged from
+    inputs.files(fileTree(rootProject.layout.projectDirectory.dir('../grails-skills')) { include('*/skills/**') })
+            .withPropertyName('skillSources')
+            .withPathSensitivity(PathSensitivity.RELATIVE)
+}
diff --git a/end-to-end/agent-skills/src/test/groovy/org/apache/grails/e2e/skills/AgentSkillsSpec.groovy b/end-to-end/agent-skills/src/test/groovy/org/apache/grails/e2e/skills/AgentSkillsSpec.groovy
new file mode 100644
index 0000000000..6a67b4a4a6
--- /dev/null
+++ b/end-to-end/agent-skills/src/test/groovy/org/apache/grails/e2e/skills/AgentSkillsSpec.groovy
@@ -0,0 +1,105 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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.apache.grails.e2e.skills
+
+import groovy.io.FileType
+
+import spock.lang.Specification
+
+/**
+ * The agent skills Grails publishes must reach a build the way the guide describes.
+ *
+ * <p>This application declares the skills without a version, so they resolve through the Grails
+ * BOM that the Grails Gradle plugin applies; {@code agent-skills-plain-build} names the BOM itself.
+ * Both extract them with the SkillsJars Gradle plugin, which reads the SkillsJars layout each jar
+ * holds its skill in, {@code META-INF/skills/<org>/<repo>/<skill>/}, and writes each skill to a
+ * {@code skillsjars__<org>__<repo>__<skill>} directory. The test task runs both builds'
+ * extractSkillsJars first.
+ *
+ * <p>Paths are relative to this project's directory, where the test runs.
+ */
+class AgentSkillsSpec extends Specification {
+
+    // Each published skill and the directory in the grails-skills projects its jar is packaged from
+    private static final Map<String, File> PUBLISHED_SKILLS = [
+            'grails-developer': new File('../../grails-skills/developer/skills/grails-developer'),
+            'grails-8-upgrade': new File('../../grails-skills/upgrade-guide-8/skills/grails-8-upgrade'),
+    ]
+
+    private static final Map<String, File> EXTRACTED_BY = [
+            'a Grails application': new File('.agents/skills'),
+            'a build without the Grails Gradle plugin': new File('../agent-skills-plain-build/.agents/skills'),
+    ]
+
+    private static final File REPOSITORY_SKILLS = new File('../../.agents/skills')
+
+    void 'every published skill is extracted by #consumer and nothing else is'() {
+        expect:
+        (EXTRACTED_BY[consumer].list() as Set) == (PUBLISHED_SKILLS.keySet().collect { extractedName(it) } as Set)
+
+        where:
+        consumer << EXTRACTED_BY.keySet()
+    }
+
+    void '#consumer extracts the #skill skill unchanged'() {
+        given:
+        File extracted = new File(EXTRACTED_BY[consumer], extractedName(skill))
+
+        expect: 'exactly the files it was published from'
+        fileContents(extracted) == fileContents(PUBLISHED_SKILLS[skill])
+
+        and: 'declaring the name agents identify it by'
+        frontmatterName(new File(extracted, 'SKILL.md')) == skill
+
+        where:
+        [consumer, skill] << [EXTRACTED_BY.keySet(), PUBLISHED_SKILLS.keySet()].combinations()
+    }
+
+    void 'agents working in grails-core see the #skill skill as it is published'() {
+        expect: 'the entry in .agents/skills is the directory the jar is packaged from, not a copy of it'
+        new File(REPOSITORY_SKILLS, skill).canonicalFile == PUBLISHED_SKILLS[skill].canonicalFile
+
+        where:
+        skill << PUBLISHED_SKILLS.keySet()
+    }
+
+    // The directory the SkillsJars plugin writes a skill of this repository to
+    private static String extractedName(String skill) {
+        "skillsjars__apache__grails-core__${skill}"
+    }
+
+    // Every file under the directory, by its path relative to it
+    private static Map<String, String> fileContents(File directory) {
+        assert directory.directory : "${directory} is not a directory"
+        Map<String, String> contents = [:]
+        directory.eachFileRecurse(FileType.FILES) { File file ->
+            contents[directory.toPath().relativize(file.toPath()).toString()] = file.getText('UTF-8')
+        }
+        contents
+    }
+
+    private static String frontmatterName(File skill) {
+        List<String> lines = skill.readLines('UTF-8')
+        assert lines && lines.first() == '---' : "${skill} does not start with frontmatter"
+        int end = lines.subList(1, lines.size()).indexOf('---') + 1
+        assert end > 0 : "${skill} does not close its frontmatter"
+        String name = lines.subList(1, end).find { it.startsWith('name:') }
+        name?.substring('name:'.length())?.trim()
+    }
+}
diff --git a/end-to-end/settings.gradle b/end-to-end/settings.gradle
index 428275ad5b..d6247c59f9 100644
--- a/end-to-end/settings.gradle
+++ b/end-to-end/settings.gradle
@@ -102,6 +102,10 @@ dependencyResolutionManagement {
 rootProject.name = 'grails-end-to-end'
 
 include(
+        // Example builds that resolve the published agent skill jars through the published BOM and
+        // extract them the way the guide shows. Only published metadata can prove a build gets them.
+        'agent-skills',
+        'agent-skills-plain-build',
         'legacy-commands',
         'legacy-commands-plugin',
         // Builds an application twice without a clean, to prove a renamed or deleted tag library
diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle
index a79047cf0a..ab9134d570 100644
--- a/gradle/publish-root-config.gradle
+++ b/gradle/publish-root-config.gradle
@@ -178,7 +178,7 @@ def publishedProjects = [
 ]
 
 subprojects {
-    if (name in publishedProjects) {
+    if (name in publishedProjects || name in skillProjects) {
         // This has to be applied here in the root project due to the nexus plugin requirements
         if (name.startsWith('grails-profiles')) {
             apply plugin: 'org.apache.grails.gradle.grails-publish-profile'
diff --git a/gradle/rat-root-config.gradle b/gradle/rat-root-config.gradle
index 45c78f6e80..7bcffb4c97 100644
--- a/gradle/rat-root-config.gradle
+++ b/gradle/rat-root-config.gradle
@@ -128,6 +128,7 @@ tasks.named('rat') {
             'grails-forge/**/src/test/resources/**', // src/test/resources are used in tests against files included in generated application and should not include a license
             'grails-gradle/**/build/**', // grails-gradle does not have a build package name so exclude any build directories
             'end-to-end/**/build/**', // its own build, so its build directories are not covered by the root exclude
+            'end-to-end/*/.agents/**', // skills the agent-skills example builds extract; gitignored build output
             'grails-forge/*/build/**', // grails-forge build directories
             'grails-forge/build/**', // grails-forge build directories
             'grails-spring-security/plugin/src/main/templates/**', // template files that people are expected to use in the end application
diff --git a/grails-doc/src/en/guide/gettingStarted/agentSkills.adoc b/grails-doc/src/en/guide/gettingStarted/agentSkills.adoc
new file mode 100644
index 0000000000..00b907865f
--- /dev/null
+++ b/grails-doc/src/en/guide/gettingStarted/agentSkills.adoc
@@ -0,0 +1,100 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you 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.
+////
+
+Grails publishes https://agentskills.io[Agent Skills] that teach AI coding agents how to work with Grails. A skill is a `SKILL.md` file of instructions that an agent loads when a task calls for it.
+
+[options="header",cols="1,2,3"]
+|===
+| Skill | Coordinates | Use
+
+| `grails-developer` | `org.apache.grails.skills:grails-developer` | Building Grails applications: controllers, services, GORM, views, plugins, and testing with Spock and Geb
+| `grails-8-upgrade` | `org.apache.grails.skills:grails-8-upgrade` | Upgrading an application from Grails 7 to Grails 8
+|===
+
+The skills are released with Grails, under the same version, and the Grails BOM manages them, so an application gets the skills written for the Grails version it builds with.
+
+==== Adding the Skills to an Application
+
+Agents read skills from directories in the repository rather than from the classpath. The https://github.com/skillsjars/skillsjars-gradle-plugin[SkillsJars Gradle plugin] extracts skill jars into such a directory. Apply it, declare the skills in the `skill` configuration it adds, and set the directory to write them to:
+
+[source,groovy]
+.build.gradle
+----
+plugins {
+    // the application's other plugins
+    id 'com.skillsjars.gradle-plugin' version '0.1.3'
+}
+
+dependencies {
+    skill 'org.apache.grails.skills:grails-developer'
+    skill 'org.apache.grails.skills:grails-8-upgrade'
+}
+
+skillsjars {
+    outputDir = layout.projectDirectory.dir('.agents/skills')
+}
+----
+
+The Grails skills need version 0.1.3 or later of the SkillsJars Gradle plugin, so raise the version of any snippet that names an earlier one. Versions before 0.1.0 extract only skills in the `com.skillsjars` group, and 0.1.0 to 0.1.2 fail the build of any project that applies the `java` plugin, which includes every Grails application.
+
+Then extract them:
+
+[source,bash]
+----
+./gradlew extractSkillsJars
+----
+
+Each skill is written to a directory named after its path in the jar, such as `+.agents/skills/skillsjars__apache__grails-core__grails-developer/SKILL.md+`. Set `outputDir` to the directory your agent reads skills from; Claude Code, for example, reads `.claude/skills`. `-PoutputDir=<directory>` overrides it for a single run.
+
+WARNING: `extractSkillsJars` deletes everything in the output directory before it writes the skills, and `clean` deletes the directory. Point it at a directory that holds only extracted skills, not one with skills the project keeps itself.
+
+The `skill` configuration stays off the application's compile and runtime classpaths. The Grails Gradle plugin applies the Grails BOM to it, as it does to the application's other configurations, which is why the dependencies need no version.
+
+TIP: Treat the extracted skills as build output: exclude them from version control and run `extractSkillsJars` again after upgrading Grails, so the skills match the new version.
+
+==== Builds Without the Grails Gradle Plugin
+
+A build that does not apply the Grails Gradle plugin, such as the root of a multi-project build whose Grails applications are subprojects, applies the SkillsJars Gradle plugin the same way and adds the Grails BOM to the `skill` configuration itself:
+
+[source,groovy,subs="attributes"]
+.build.gradle
+----
+dependencies {
+    skill platform('org.apache.grails:grails-bom:{version}')
+    skill 'org.apache.grails.skills:grails-developer'
+}
+----
+
+==== Grails 7 Applications
+
+The upgrade skill is for applications still on Grails 7. They apply the SkillsJars Gradle plugin as above, but the Grails 7 BOM does not manage the skills, so they name the version:
+
+[source,groovy,subs="attributes"]
+.build.gradle
+----
+dependencies {
+    skill 'org.apache.grails.skills:grails-8-upgrade:{version}'
+}
+----
+
+Once the application is on Grails 8, the version can be dropped.
+
+==== The SkillsJars Layout
+
+Each Grails skill jar holds its skill under `META-INF/skills/apache/grails-core/<skill>/`, the layout defined by https://www.skillsjars.com[SkillsJars], so other SkillsJars tools, such as its Maven plugin, extract the Grails skills as well. Skills published by other projects in that layout can be added to the `skill` configuration alongside them.
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index 8cf36a8344..c25ea77af7 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -404,6 +404,10 @@ deployment whose reverse proxy already sends these headers can set `grails.secur
 only explicitly configured headers on proxied requests. See the xref:security[Security] section of the user guide and
 the xref:upgrading#upgrading80x[upgrade guide] for details.
 
+==== Agent Skills Published with Grails
+
+Grails now publishes its app-facing https://agentskills.io[Agent Skills] with every release: `org.apache.grails.skills:grails-developer`, for building Grails applications, and `org.apache.grails.skills:grails-8-upgrade`, for upgrading an application from Grails 7. The jars use the https://www.skillsjars.com[SkillsJars] layout and the Grails BOM manages them, so an application applies the SkillsJars Gradle plugin, declares the skills without a version, and runs `./gradlew extractSkillsJar [...]
+
 ==== Test HTTP Client Form Requests
 
 Integration tests that implement `HttpClientSupport` can now use `httpPostForm(...)` to send `application/x-www-form-urlencoded`
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index d9fb68c29e..3b704f8224 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -26,6 +26,7 @@ gettingStarted:
   aHelloWorldExample: Creating a Simple Web Application with Grails
   usingInteractiveMode: Using Interactive Mode
   ide: Getting Set Up in an IDE
+  agentSkills: Agent Skills
   conventionOverConfiguration: Grails Directory Structure and Convention over Configuration
   runningAndDebuggingAnApplication: Running and Debugging an Application
   testingAnApplication: Testing an Application
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 7e07d8144a..d8c58b6334 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -38,6 +38,20 @@ Boot the application once; the migrator logs warnings for every deprecated prope
 Remove the dependency once your configuration is clean.
 ====
 
+[TIP]
+====
+**Let your coding agent work through this guide.**
+Grails publishes the `grails-8-upgrade` agent skill, which turns this guide into a migration checklist for an AI coding agent.
+Extract it with the SkillsJars Gradle plugin, as xref:gettingStarted#agentSkills[Agent Skills] describes.
+While the application is still on Grails 7, declare the skill with its version, since the Grails 7 BOM does not manage it:
+
+[source,groovy,subs="attributes"]
+.build.gradle
+----
+skill 'org.apache.grails.skills:grails-8-upgrade:{version}'
+----
+====
+
 ==== 1. Java 21 Minimum Requirement
 
 Grails 8 requires a minimum of Java 21 to both build and run Grails applications.
diff --git a/grails-skills/developer/build.gradle b/grails-skills/developer/build.gradle
new file mode 100644
index 0000000000..4c1abe78bf
--- /dev/null
+++ b/grails-skills/developer/build.gradle
@@ -0,0 +1,33 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You 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.
+ */
+plugins {
+    id 'java'
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.publish'
+    id 'org.apache.grails.buildsrc.sbom'
+    id 'org.apache.grails.buildsrc.agent-skills'
+}
+
+version = projectVersion
+group = 'org.apache.grails.skills'
+
+ext {
+    pomArtifactId = 'grails-developer'
+    pomTitle = 'Apache Grails® Developer Agent Skill'
+    pomDescription = 'Agent Skill that guides AI coding agents building Apache Grails® applications: controllers, services, GORM, views, plugins, and testing with Spock and Geb.'
+}
diff --git a/.agents/skills/grails-developer/SKILL.md b/grails-skills/developer/skills/grails-developer/SKILL.md
similarity index 97%
rename from .agents/skills/grails-developer/SKILL.md
rename to grails-skills/developer/skills/grails-developer/SKILL.md
index 8e83754866..1de500c991 100644
--- a/.agents/skills/grails-developer/SKILL.md
+++ b/grails-skills/developer/skills/grails-developer/SKILL.md
@@ -1,6 +1,6 @@
 ---
 name: grails-developer
-description: Comprehensive guide for current Grails development, covering web applications, REST APIs, GORM, controllers, services, views, plugins, and testing with Spock and Geb
+description: Comprehensive guide for Grails development, covering web applications, REST APIs, GORM, controllers, services, views, plugins, and testing with Spock and Geb
 license: Apache-2.0
 ---
 <!--
@@ -11,15 +11,15 @@ Licensed to the Apache Software Foundation (ASF) under one or more contributor l
 
 ## What I Do
 
-- Provide detailed guidance for building current Grails web applications and REST APIs.
+- Provide detailed guidance for building Grails web applications and REST APIs.
 - Assist with GORM for data modeling, controllers for request handling, services for business logic, and views (GSP, JSON, Markup).
-- Support testing with Spock 2.4 (unit/integration tests) and Geb for browser automation.
+- Support testing with Spock (unit/integration tests) and Geb for browser automation.
 - Guide plugin usage and development, security implementation, and deployment strategies.
 - Help with configuration, internationalization, async programming, and performance optimization.
 
 ## When to Use Me
 
-Activate this skill when developing with current Grails, including:
+Activate this skill when developing with Grails, including:
 
 - Building CRUD applications, RESTful APIs, or full-stack web applications.
 - Working with GORM domain classes, constraints, and relationships.
@@ -30,11 +30,11 @@ Activate this skill when developing with current Grails, including:
 
 ## Technology Stack
 
-Current Grails is built on:
+Grails is built on:
 - **Spring Boot**: 4.1.x
 - **Spring Framework**: 7.0.x
 - **Groovy**: 5.1.x
-- **Gradle**: 9.6.x
+- **Gradle**: 9.7.x
 - **Spock**: 2.4-groovy-5.0
 - **Jakarta EE**: 10 (migrated from javax.*)
 - **Micronaut**: Optional via `grails-micronaut` plugin
diff --git a/grails-skills/upgrade-guide-8/build.gradle b/grails-skills/upgrade-guide-8/build.gradle
new file mode 100644
index 0000000000..f695d7f5a7
--- /dev/null
+++ b/grails-skills/upgrade-guide-8/build.gradle
@@ -0,0 +1,33 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You 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.
+ */
+plugins {
+    id 'java'
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.publish'
+    id 'org.apache.grails.buildsrc.sbom'
+    id 'org.apache.grails.buildsrc.agent-skills'
+}
+
+version = projectVersion
+group = 'org.apache.grails.skills'
+
+ext {
+    pomArtifactId = 'grails-8-upgrade'
+    pomTitle = 'Apache Grails® 8 Upgrade Agent Skill'
+    pomDescription = 'Agent Skill that guides AI coding agents upgrading Apache Grails® applications from Grails 7.x to Grails 8.'
+}
diff --git a/.agents/skills/grails-8-upgrade/SKILL.md b/grails-skills/upgrade-guide-8/skills/grails-8-upgrade/SKILL.md
similarity index 88%
rename from .agents/skills/grails-8-upgrade/SKILL.md
rename to grails-skills/upgrade-guide-8/skills/grails-8-upgrade/SKILL.md
index d0037fc458..7f46857e96 100644
--- a/.agents/skills/grails-8-upgrade/SKILL.md
+++ b/grails-skills/upgrade-guide-8/skills/grails-8-upgrade/SKILL.md
@@ -29,20 +29,20 @@ Activate this skill when:
 
 ## Primary Sources
 
-Use these repository docs as the source of truth before changing an application:
+Use the published Grails documentation as the source of truth before changing an application. The URLs below use `<version>`, which stands for the Grails 8 version the application is upgrading to, such as the `grailsVersion` in its `gradle.properties`; use `snapshot` for a snapshot version.
 
-| Path | Use for |
-|------|---------|
-| `grails-doc/src/en/guide/upgrading/upgrading80x.adoc` | Main Grails 7 to Grails 8 upgrade guide |
-| `grails-doc/src/en/guide/introduction/whatsNew.adoc` | Grails 8 feature and platform overview |
-| `grails-doc/src/en/guide/introduction/whatsNew/dependencyUpgrades.adoc` | Current platform dependency baseline |
-| `grails-doc/src/en/ref/Dependency Versions.adoc` | BOM variants and dependency-version entry points |
-| `grails-doc/src/en/guide/conf/micronaut.adoc` | Micronaut BOM usage, Hibernate-specific Micronaut BOMs, and JDK 25 requirements |
-| `grails-doc/src/en/guide/theWebLayer/contentNegotiation.adoc` | MIME defaults and Accept header behavior |
-| `grails-data-hibernate7/docs/src/docs/asciidoc/introduction/upgradeNotes.adoc` | Hibernate 7 GORM query and tenant-schema notes |
-| `grails-data-hibernate7/dbmigration/README.md` | Database migration plugin version line for Grails 8 and Hibernate 7 |
+| URL | Use for |
+|-----|---------|
+| `https://grails.apache.org/docs/<version>/guide/upgrading.html#upgrading80x` | Main Grails 7 to Grails 8 upgrade guide |
+| `https://grails.apache.org/docs/<version>/guide/introduction.html#whatsNew` | Grails 8 feature and platform overview |
+| `https://grails.apache.org/docs/<version>/guide/introduction.html#dependencyUpgrades` | Platform dependency baseline |
+| `https://grails.apache.org/docs/<version>/ref/Versions/Grails%20BOM.html` | Dependency versions of the Grails BOM, with links to the Hibernate 5, Hibernate 7, and Neo4j BOM variants |
+| `https://grails.apache.org/docs/<version>/guide/upgrading.html#_7_micronaut_integration` | Micronaut integration, which points to the Grails Micronaut project's own upgrade documentation |
+| `https://grails.apache.org/docs/<version>/guide/theWebLayer.html#contentNegotiation` | MIME defaults and Accept header behavior |
+| `https://grails.apache.org/docs/<version>/grails-data/hibernate7/manual/index.html#upgradeNotes` | Hibernate 7 GORM query and tenant-schema notes |
+| `https://grails.apache.org/docs/<version>/grails-data/hibernate7/manual/index.html#databaseMigration` | Database migration plugin for Hibernate 7, which uses the same version as Grails |
 
-When checking published docs, use explicit Grails 8 URLs such as `https://grails.apache.org/docs/8.0.0-M1/guide/upgrading.html` or `https://grails.apache.org/docs/snapshot/guide/upgrading.html`. Do not assume `https://grails.apache.org/docs/latest/guide/upgrading.html` points at Grails 8.
+Do not assume `https://grails.apache.org/docs/latest/` points at Grails 8.
 
 ## Upgrade Baseline
 
@@ -50,7 +50,7 @@ Start every Grails 8 upgrade by checking these platform requirements:
 
 - Use JDK 21 or later to build and run ordinary Grails 8 applications.
 - Use JDK 25 or later if the application uses `grails-micronaut`, `micronaut-http-client`, or other Micronaut features.
-- Update the Gradle wrapper to the Grails 8 managed line. Current 8.0.x snapshot docs use Gradle 9.7.1, while milestone docs may show an earlier Gradle 9.x version.
+- Update the Gradle wrapper to Gradle 9.7 or later, which the Grails 8 Gradle plugins require.
 - Expect Spring Boot 4.1.x, Spring Framework 7.0.x, Spring Security 7.1.x, Spring Data 2026.0.x, Micrometer 1.17.x, Jackson 3.1.x, Tomcat 11.0.x, and Jakarta Servlet 6.1.
 - Keep using `jakarta.*` APIs. Do not reintroduce `javax.*` packages.
 - Add `runtimeOnly 'org.springframework.boot:spring-boot-properties-migrator'` temporarily during the migration, boot once, fix reported configuration properties, then remove it.
@@ -91,7 +91,11 @@ Spring Boot 4 renamed common starters:
 
 For WAR deployment to an external servlet container, replace `providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'` with `providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat-runtime'`.
 
-Undertow is not available in Grails 8 until Undertow supports Servlet 6.1. Use Tomcat or Jetty.
+Spring Boot 4 dropped `spring-boot-starter-undertow`; Grails 8 provides Undertow through the Grails Undertow plugin instead. For an application that runs on Undertow, make the build match what Grails Forge generates for `--servlet=undertow`:
+
+- Replace `spring-boot-starter-undertow` with `implementation 'org.apache.grails:grails-undertow'`, without a version; the Grails BOM manages it.
+- Do not declare `spring-boot-starter-tomcat` or `spring-boot-tomcat`; those are what Forge adds for Tomcat applications instead.
+- Keep the `server.undertow.*` properties; they continue to work. `server.undertow.max-http-post-size` now defaults to 2MB, Undertow's hardened default; set it to `-1` only if the application relied on the previous unlimited request size.
 
 Spring Retry is no longer managed by Spring Boot. If the application directly uses `@Retryable`, `@EnableRetry`, or `@Recover`, add `implementation 'org.springframework.retry:spring-retry'`.
 
diff --git a/settings.gradle b/settings.gradle
index 6e27b69184..c128bda383 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -225,6 +225,10 @@ include(
 
         // wrapper
         'grails-wrapper',
+
+        // Agent Skills (published for consumption by AI coding agents)
+        'grails-skills-developer',
+        'grails-skills-upgrade-guide-8',
 )
 
 project(':grails-bom').projectDir = file('grails-bom/default')
@@ -251,6 +255,9 @@ project(':grails-async-rxjava2').projectDir = file('grails-async/rxjava2')
 project(':grails-async-rxjava3').projectDir = file('grails-async/rxjava3')
 project(':grails-async').projectDir = file('grails-async/plugin')
 
+project(':grails-skills-developer').projectDir = file('grails-skills/developer')
+project(':grails-skills-upgrade-guide-8').projectDir = file('grails-skills/upgrade-guide-8')
+
 project(':grails-dependencies-starter-web').projectDir = file('grails-dependencies/starter-web')
 project(':grails-dependencies-test').projectDir = file('grails-dependencies/test')
 project(':grails-dependencies-assets').projectDir = file('grails-dependencies/assets')