diff --git a/customHttp.yml b/customHttp.yml
index 9e866dc7f7f..3601f0c5980 100644
--- a/customHttp.yml
+++ b/customHttp.yml
@@ -15,3 +15,34 @@ customHeaders:
- key: 'Content-Security-Policy'
value: 'upgrade-insecure-requests;'
# CSP also set in _document.tsx meta tag
+ # Link headers advertise agent-discovery resources (RFC 8288 / RFC 9727):
+ # the API catalog, the agent skills index, the MCP server card, and the
+ # LLM-friendly documentation index.
+ #
+ # Deliberately kept on the global block. Amplify header patterns are
+ # positive-match only (no exclusion), and with trailingSlash: true the
+ # actual page requests are extensionless directory URLs (e.g.
+ # /react/build-a-backend/), so an `**/*.html` pattern would miss real
+ # page loads and drop the header where agents need it most. The header
+ # therefore also rides asset responses (a few extra bytes); the
+ # missing-file risk this could have amplified is eliminated because the
+ # generators now fail the build if a linked file is not written.
+ - key: 'Link'
+ value: '; rel="api-catalog", ; rel="agent-skills"; type="application/json", ; rel="mcp-server"; type="application/json", ; rel="service-doc"; type="text/plain"'
+ # Serve the API catalog as a linkset (RFC 9727 / RFC 9264) at both the
+ # canonical extensionless path (served via a 200-rewrite in redirects.json)
+ # and the underlying .json file.
+ - pattern: '/.well-known/api-catalog'
+ headers:
+ - key: 'Content-Type'
+ value: 'application/linkset+json'
+ - pattern: '/.well-known/api-catalog.json'
+ headers:
+ - key: 'Content-Type'
+ value: 'application/linkset+json'
+ # Serve the generated agent-facing markdown twins as markdown so agents that
+ # request them get the correct media type (these live under /ai/**).
+ - pattern: '/ai/**/*.md'
+ headers:
+ - key: 'Content-Type'
+ value: 'text/markdown; charset=utf-8'
diff --git a/package.json b/package.json
index ccf8e47ce8d..585d9c203a3 100644
--- a/package.json
+++ b/package.json
@@ -129,5 +129,5 @@
"overrides": {
"tmp": "^0.2.4"
},
- "packageManager": "yarn@4.14.1"
+ "packageManager": "yarn@4.17.0"
}
diff --git a/redirects.json b/redirects.json
index 876cd77c5d8..9fbc2da6f97 100644
--- a/redirects.json
+++ b/redirects.json
@@ -1,4 +1,9 @@
[
+ {
+ "source": "/.well-known/api-catalog",
+ "target": "/.well-known/api-catalog.json",
+ "status": "200"
+ },
{
"source": "/lib/ssr/ssr/q/platform/js/",
"target": "/gen1/javascript/prev/build-a-backend/server-side-rendering/",
diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx
index e1bc3a8744a..f2235e4e745 100644
--- a/src/components/Layout/Layout.tsx
+++ b/src/components/Layout/Layout.tsx
@@ -44,7 +44,8 @@ import { getPageSection } from '@/utils/getPageSection';
import { PinpointEOLBanner } from '@/components/PinpointEOLBanner';
import { LexV1EOLBanner } from '../LexV1EOLBanner';
import { ApiModalProvider } from '../ApiDocs/ApiModalProvider';
-import { MarkdownMenu } from '@/components/MarkdownMenu';
+import { MarkdownMenu, getMarkdownUrl } from '@/components/MarkdownMenu';
+import { WebMcp } from '@/components/WebMcp';
export const Layout = ({
children,
@@ -167,9 +168,17 @@ export const Layout = ({
};
const isOverview =
- children?.props?.childPageNodes?.length != 'undefined' &&
+ typeof children?.props?.childPageNodes?.length !== 'undefined' &&
children?.props?.childPageNodes?.length > 0;
+ // Per-page markdown alternate for agent autodiscovery. Only Gen2 content
+ // pages have a generated /ai/pages/*.md twin — mirror the MarkdownMenu gate
+ // (skip Gen1, home, and overview pages) so we never advertise a missing file.
+ const markdownUrl =
+ !isGen1 && !isHome && !isOverview
+ ? getMarkdownUrl(asPathWithNoHash)
+ : null;
+
const showNextPrev = NEXT_PREVIOUS_SECTIONS.some(
(section) =>
pathname.includes(section) &&
@@ -267,6 +276,14 @@ export const Layout = ({
<>
{`${title}`}
+ {markdownUrl && (
+
+ )}
+ {markdownUrl && }
{
+ const response = await fetch(url, {
+ headers: { accept: 'text/markdown, text/plain' }
+ });
+ if (!response.ok) {
+ throw new Error(`Request for ${url} failed with status ${response.status}`);
+ }
+ const text = await response.text();
+ if (/^\s* {
try {
- const response = await fetch(mdUrl);
- if (!response.ok) return;
- const text = await response.text();
- // Guard against accidentally copying HTML (e.g. 404 page)
- if (/^\s* setCopied(false), 2000);
} catch {
- // Silently fail if clipboard not available
+ // Silently fail if the markdown is unavailable or clipboard is blocked
}
}, [mdUrl]);
diff --git a/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx b/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx
index 6b4e314aaaa..73ceb904a27 100644
--- a/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx
+++ b/src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx
@@ -1,7 +1,11 @@
import * as React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { MarkdownMenu } from '../MarkdownMenu';
+import {
+ MarkdownMenu,
+ getMarkdownUrl,
+ fetchPageMarkdown
+} from '../MarkdownMenu';
// Mock fetch and clipboard
const mockFetch = jest.fn();
@@ -75,7 +79,10 @@ describe('MarkdownMenu', () => {
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
- '/ai/pages/build-a-backend/auth/set-up-auth.md'
+ '/ai/pages/build-a-backend/auth/set-up-auth.md',
+ expect.objectContaining({
+ headers: expect.objectContaining({ accept: expect.any(String) })
+ })
);
});
expect(mockWriteText).toHaveBeenCalledWith(mdContent);
@@ -119,7 +126,10 @@ describe('MarkdownMenu', () => {
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
- '/ai/pages/build-a-backend/auth/set-up-auth.md'
+ '/ai/pages/build-a-backend/auth/set-up-auth.md',
+ expect.objectContaining({
+ headers: expect.objectContaining({ accept: expect.any(String) })
+ })
);
});
});
@@ -147,3 +157,53 @@ describe('MarkdownMenu', () => {
});
});
});
+
+describe('getMarkdownUrl', () => {
+ it('maps a platform route to its markdown twin', () => {
+ expect(getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/')).toBe(
+ '/ai/pages/build-a-backend/auth/set-up-auth.md'
+ );
+ });
+
+ it('strips a query string before building the URL', () => {
+ expect(
+ getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/?foo=bar')
+ ).toBe('/ai/pages/build-a-backend/auth/set-up-auth.md');
+ });
+
+ it('strips a hash fragment before building the URL', () => {
+ expect(
+ getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/#section')
+ ).toBe('/ai/pages/build-a-backend/auth/set-up-auth.md');
+ });
+});
+
+describe('fetchPageMarkdown', () => {
+ beforeEach(() => {
+ global.fetch = mockFetch;
+ mockFetch.mockReset();
+ });
+
+ it('returns the markdown text on a successful response', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve('# Title\n\nBody')
+ });
+ await expect(fetchPageMarkdown('/ai/pages/x.md')).resolves.toBe(
+ '# Title\n\nBody'
+ );
+ });
+
+ it('throws when the response is not ok', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
+ await expect(fetchPageMarkdown('/ai/pages/missing.md')).rejects.toThrow();
+ });
+
+ it('throws when the SPA HTML fallback is served instead of markdown', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve('404')
+ });
+ await expect(fetchPageMarkdown('/ai/pages/x.md')).rejects.toThrow();
+ });
+});
diff --git a/src/components/MarkdownMenu/index.ts b/src/components/MarkdownMenu/index.ts
index daceaa3ef68..7c029a1b738 100644
--- a/src/components/MarkdownMenu/index.ts
+++ b/src/components/MarkdownMenu/index.ts
@@ -1 +1 @@
-export { MarkdownMenu } from './MarkdownMenu';
+export { MarkdownMenu, getMarkdownUrl, fetchPageMarkdown } from './MarkdownMenu';
diff --git a/src/components/WebMcp/WebMcp.tsx b/src/components/WebMcp/WebMcp.tsx
new file mode 100644
index 00000000000..9d21c367d5d
--- /dev/null
+++ b/src/components/WebMcp/WebMcp.tsx
@@ -0,0 +1,113 @@
+import { useEffect } from 'react';
+import { getMarkdownUrl, fetchPageMarkdown } from '@/components/MarkdownMenu';
+
+/**
+ * Minimal shape of the WebMCP API we depend on. The spec exposes
+ * `document.modelContext.registerTool`; some early implementations also alias
+ * it on `navigator.modelContext`. We feature-detect both and treat the API as
+ * entirely optional so this is a silent no-op in browsers without WebMCP.
+ *
+ * See https://webmachinelearning.github.io/webmcp/
+ */
+interface ModelContextTool {
+ name: string;
+ description: string;
+ inputSchema?: object;
+ annotations?: { readOnlyHint?: boolean };
+ execute: (input: Record) => Promise;
+}
+
+interface ModelContextLike {
+ registerTool: (
+ tool: ModelContextTool,
+ options?: { signal?: AbortSignal }
+ ) => Promise;
+}
+
+function getModelContext(): ModelContextLike | null {
+ if (typeof document !== 'undefined') {
+ const fromDoc = (document as unknown as { modelContext?: ModelContextLike })
+ .modelContext;
+ if (fromDoc?.registerTool) return fromDoc;
+ }
+ if (typeof navigator !== 'undefined') {
+ const fromNav = (navigator as unknown as { modelContext?: ModelContextLike })
+ .modelContext;
+ if (fromNav?.registerTool) return fromNav;
+ }
+ return null;
+}
+
+/**
+ * Register a single tool, isolating failures so one rejected registration
+ * (e.g. a transient duplicate-name error during abort/re-register on fast
+ * client-side navigation) can't prevent the others from registering.
+ */
+async function safeRegister(
+ modelContext: ModelContextLike,
+ tool: ModelContextTool,
+ signal: AbortSignal
+): Promise {
+ try {
+ await modelContext.registerTool(tool, { signal });
+ } catch {
+ // Registration is best-effort; ignore failures for this tool.
+ }
+}
+
+/**
+ * Registers read-only WebMCP tools that expose this documentation site's key
+ * actions to in-browser AI agents. Every tool is backed by content the site
+ * already generates (the per-page markdown twins under /ai/pages and the
+ * llms.txt index), so the tools return real data rather than stubs.
+ *
+ * Renders nothing; the registration happens as a side effect on mount and is
+ * torn down via an AbortSignal on unmount.
+ */
+export function WebMcp({ route }: { route: string }) {
+ useEffect(() => {
+ const modelContext = getModelContext();
+ if (!modelContext) return;
+
+ const controller = new AbortController();
+ const { signal } = controller;
+ const origin = window.location.origin;
+
+ // Each tool is registered independently so one failure can't block the rest.
+ safeRegister(
+ modelContext,
+ {
+ name: 'get_current_page_markdown',
+ description:
+ 'Return the current AWS Amplify documentation page as clean Markdown, ideal for reading or summarizing without HTML chrome.',
+ inputSchema: { type: 'object', properties: {} },
+ annotations: { readOnlyHint: true },
+ execute: async () => {
+ const markdown = await fetchPageMarkdown(origin + getMarkdownUrl(route));
+ return { markdown };
+ }
+ },
+ signal
+ );
+
+ safeRegister(
+ modelContext,
+ {
+ name: 'get_documentation_index',
+ description:
+ 'Return the AWS Amplify documentation index (llms.txt), a Markdown list of all documentation pages with descriptions and links to their Markdown versions.',
+ inputSchema: { type: 'object', properties: {} },
+ annotations: { readOnlyHint: true },
+ execute: async () => {
+ const index = await fetchPageMarkdown(origin + '/ai/llms.txt');
+ return { index };
+ }
+ },
+ signal
+ );
+
+ return () => controller.abort();
+ }, [route]);
+
+ return null;
+}
diff --git a/src/components/WebMcp/__tests__/WebMcp.test.tsx b/src/components/WebMcp/__tests__/WebMcp.test.tsx
new file mode 100644
index 00000000000..ecc4743c48b
--- /dev/null
+++ b/src/components/WebMcp/__tests__/WebMcp.test.tsx
@@ -0,0 +1,94 @@
+import * as React from 'react';
+import { render, waitFor, act } from '@testing-library/react';
+import { WebMcp } from '../WebMcp';
+
+describe('WebMcp', () => {
+ afterEach(() => {
+ // Remove any modelContext shim we attached during a test.
+ delete (document as unknown as { modelContext?: unknown }).modelContext;
+ jest.restoreAllMocks();
+ });
+
+ it('is a no-op when the WebMCP API is unavailable', () => {
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('registers read-only tools when document.modelContext exists', async () => {
+ const registerTool = jest.fn().mockResolvedValue(undefined);
+ (document as unknown as { modelContext: unknown }).modelContext = {
+ registerTool
+ };
+
+ await act(async () => {
+ render();
+ });
+
+ await waitFor(() => expect(registerTool).toHaveBeenCalled());
+
+ const toolNames = registerTool.mock.calls.map((c) => c[0].name);
+ expect(toolNames).toContain('get_current_page_markdown');
+ expect(toolNames).toContain('get_documentation_index');
+
+ // Each registered tool must have the WebMCP-required fields.
+ for (const [tool] of registerTool.mock.calls) {
+ expect(typeof tool.name).toBe('string');
+ expect(typeof tool.description).toBe('string');
+ expect(typeof tool.execute).toBe('function');
+ expect(tool.annotations.readOnlyHint).toBe(true);
+ }
+ });
+
+ it('fetches the current page markdown when its tool is executed', async () => {
+ const registerTool = jest.fn().mockResolvedValue(undefined);
+ (document as unknown as { modelContext: unknown }).modelContext = {
+ registerTool
+ };
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => '# Set up auth\n\nReal markdown content.'
+ });
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await act(async () => {
+ render();
+ });
+ await waitFor(() => expect(registerTool).toHaveBeenCalled());
+
+ const pageTool = registerTool.mock.calls
+ .map((c) => c[0])
+ .find((t) => t.name === 'get_current_page_markdown');
+ const result = await pageTool.execute({});
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('/ai/pages/build-a-backend/auth/set-up-auth.md'),
+ expect.anything()
+ );
+ expect(result).toEqual({
+ markdown: '# Set up auth\n\nReal markdown content.'
+ });
+ });
+
+ it('registers the second tool even if the first registration rejects', async () => {
+ const registerTool = jest
+ .fn()
+ .mockRejectedValueOnce(new Error('duplicate tool name'))
+ .mockResolvedValueOnce(undefined);
+ (document as unknown as { modelContext: unknown }).modelContext = {
+ registerTool
+ };
+
+ await act(async () => {
+ render();
+ });
+
+ await waitFor(() => expect(registerTool).toHaveBeenCalledTimes(2));
+
+ const toolNames = registerTool.mock.calls.map((c) => c[0].name);
+ expect(toolNames).toContain('get_current_page_markdown');
+ expect(toolNames).toContain('get_documentation_index');
+ });
+});
diff --git a/src/components/WebMcp/index.ts b/src/components/WebMcp/index.ts
new file mode 100644
index 00000000000..de110a15a1c
--- /dev/null
+++ b/src/components/WebMcp/index.ts
@@ -0,0 +1 @@
+export { WebMcp } from './WebMcp';
diff --git a/tasks/__tests__/generate-agent-skills.test.ts b/tasks/__tests__/generate-agent-skills.test.ts
new file mode 100644
index 00000000000..f83bcb359e5
--- /dev/null
+++ b/tasks/__tests__/generate-agent-skills.test.ts
@@ -0,0 +1,45 @@
+import { generateAgentSkillsIndex } from '../generate-agent-skills.mjs';
+
+describe('generate-agent-skills', () => {
+ describe('generateAgentSkillsIndex', () => {
+ it('should return a valid index JSON document with a $schema', () => {
+ const parsed = JSON.parse(
+ generateAgentSkillsIndex('https://docs.amplify.aws')
+ );
+
+ expect(typeof parsed.$schema).toBe('string');
+ expect(Array.isArray(parsed.skills)).toBe(true);
+ expect(parsed.skills.length).toBeGreaterThan(0);
+ });
+
+ it('should include the amplify-workflow skill with required fields', () => {
+ const parsed = JSON.parse(
+ generateAgentSkillsIndex('https://docs.amplify.aws')
+ );
+ const skill = parsed.skills.find((s: any) => s.name === 'amplify-workflow');
+
+ expect(skill).toBeDefined();
+ expect(skill.type).toBeTruthy();
+ expect(skill.description).toBeTruthy();
+ expect(skill.url).toBe(
+ 'https://docs.amplify.aws/react/develop-with-ai/agent-plugins/'
+ );
+ });
+
+ it('should not publish a sha256 digest for doc-pointed skills', () => {
+ const parsed = JSON.parse(
+ generateAgentSkillsIndex('https://docs.amplify.aws')
+ );
+
+ for (const skill of parsed.skills) {
+ expect(skill.sha256).toBeUndefined();
+ }
+ });
+
+ it('should honor the provided domain', () => {
+ const parsed = JSON.parse(generateAgentSkillsIndex('https://example.com'));
+
+ expect(parsed.skills[0].url.startsWith('https://example.com/')).toBe(true);
+ });
+ });
+});
diff --git a/tasks/__tests__/generate-wellknown.test.ts b/tasks/__tests__/generate-wellknown.test.ts
new file mode 100644
index 00000000000..9dede4903d2
--- /dev/null
+++ b/tasks/__tests__/generate-wellknown.test.ts
@@ -0,0 +1,132 @@
+import {
+ generateApiCatalog,
+ generateMcpServerCard
+} from '../generate-wellknown.mjs';
+import redirects from '../../redirects.json';
+
+describe('generate-wellknown', () => {
+ describe('generateApiCatalog', () => {
+ it('should return a valid linkset JSON document', () => {
+ const result = generateApiCatalog('https://docs.amplify.aws');
+ const parsed = JSON.parse(result);
+
+ expect(Array.isArray(parsed.linkset)).toBe(true);
+ expect(parsed.linkset).toHaveLength(1);
+ });
+
+ it('should anchor the catalog to the site root', () => {
+ const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws'));
+
+ expect(parsed.linkset[0].anchor).toBe('https://docs.amplify.aws/');
+ });
+
+ it('should advertise the full export as a service-desc link (RFC 9727)', () => {
+ const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws'));
+ const entry = parsed.linkset[0];
+
+ expect(Array.isArray(entry['service-desc'])).toBe(true);
+ const hrefs = entry['service-desc'].map((l: any) => l.href);
+ expect(hrefs).toContain('https://docs.amplify.aws/ai/llms-full.txt');
+ });
+
+ it('should advertise the llms.txt index as a service-doc link', () => {
+ const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws'));
+ const hrefs = parsed.linkset[0]['service-doc'].map((l: any) => l.href);
+
+ expect(hrefs).toContain('https://docs.amplify.aws/ai/llms.txt');
+ });
+
+ it('should advertise the sitemap as a service-meta link', () => {
+ const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws'));
+ const hrefs = parsed.linkset[0]['service-meta'].map((l: any) => l.href);
+
+ expect(hrefs).toContain('https://docs.amplify.aws/sitemap.xml');
+ });
+
+ it('should represent each relation as an array of href/type objects', () => {
+ const parsed = JSON.parse(generateApiCatalog('https://docs.amplify.aws'));
+ const entry = parsed.linkset[0];
+
+ for (const rel of ['service-desc', 'service-doc', 'service-meta']) {
+ for (const link of entry[rel]) {
+ expect(typeof link.href).toBe('string');
+ expect(typeof link.type).toBe('string');
+ }
+ }
+ });
+
+ it('should honor the provided domain', () => {
+ const parsed = JSON.parse(generateApiCatalog('https://example.com'));
+
+ expect(parsed.linkset[0].anchor).toBe('https://example.com/');
+ });
+ });
+
+ describe('generateMcpServerCard', () => {
+ it('should return a valid server card JSON document', () => {
+ const parsed = JSON.parse(generateMcpServerCard());
+
+ expect(parsed.serverInfo).toBeDefined();
+ expect(parsed.serverInfo.name).toBe('aws-knowledge-mcp-server');
+ });
+
+ it('should point at the public AWS Knowledge MCP endpoint over HTTP', () => {
+ const parsed = JSON.parse(generateMcpServerCard());
+
+ expect(parsed.transport.type).toBe('http');
+ expect(parsed.transport.endpoint).toBe(
+ 'https://knowledge-mcp.global.api.aws'
+ );
+ });
+
+ it('should declare that no authentication is required', () => {
+ const parsed = JSON.parse(generateMcpServerCard());
+
+ expect(parsed.authentication.required).toBe(false);
+ });
+
+ it('should advertise the documentation and skill tools', () => {
+ const parsed = JSON.parse(generateMcpServerCard());
+
+ expect(parsed.capabilities.tools).toEqual(
+ expect.arrayContaining([
+ 'search_documentation',
+ 'read_documentation',
+ 'retrieve_skill'
+ ])
+ );
+ });
+ });
+
+ // The site builds with trailingSlash: true, so the extensionless canonical
+ // path /.well-known/api-catalog is 301-redirected to a trailing-slash URL
+ // that 404s. A 200-rewrite to the .json file keeps the canonical path
+ // resolving with a 200 (the status the RFC 9727 scanner requires).
+ describe('api-catalog routing', () => {
+ it('rewrites the canonical path to the .json file with status 200', () => {
+ const rule = (redirects as any[]).find(
+ (r) => r.source === '/.well-known/api-catalog'
+ );
+
+ expect(rule).toBeDefined();
+ expect(rule.target).toBe('/.well-known/api-catalog.json');
+ expect(rule.status).toBe('200');
+ });
+
+ it('orders the rewrite before any catch-all that could shadow it', () => {
+ const rules = redirects as any[];
+ const catalogIndex = rules.findIndex(
+ (r) => r.source === '/.well-known/api-catalog'
+ );
+ // Redirects apply top-down; a broad wildcard placed earlier would win.
+ const firstCatchAllIndex = rules.findIndex((r) =>
+ /<\*>|\/\*/.test(r.source)
+ );
+
+ expect(catalogIndex).toBeGreaterThanOrEqual(0);
+ if (firstCatchAllIndex !== -1) {
+ expect(catalogIndex).toBeLessThan(firstCatchAllIndex);
+ }
+ });
+ });
+});
diff --git a/tasks/build-constants.mjs b/tasks/build-constants.mjs
new file mode 100644
index 00000000000..761dba63301
--- /dev/null
+++ b/tasks/build-constants.mjs
@@ -0,0 +1,18 @@
+import dotenv from 'dotenv';
+
+dotenv.config({ path: './.env.custom' });
+
+// Canonical site origin used to build absolute URLs in generated artifacts
+// (sitemap, robots.txt, and the .well-known discovery files).
+export const DOMAIN = process.env.SITEMAP_DOMAIN
+ ? process.env.SITEMAP_DOMAIN
+ : 'https://docs.amplify.aws';
+
+// Path of the Next.js static HTML build output that postBuildTasks writes into.
+export const ROOT_PATH = './client/www/next-build';
+
+// Canonical platform used when building absolute doc URLs for platform-agnostic
+// pages. The site requires a platform segment (a platform-neutral path 404s),
+// and the existing llms.txt generator canonicalizes on this same platform.
+// Defined once here so a canonical-platform change is a single edit.
+export const CANONICAL_PLATFORM = 'react';
diff --git a/tasks/generate-agent-skills.mjs b/tasks/generate-agent-skills.mjs
new file mode 100644
index 00000000000..d272c93a428
--- /dev/null
+++ b/tasks/generate-agent-skills.mjs
@@ -0,0 +1,67 @@
+import { promises as fs } from 'fs';
+import path from 'path';
+import { DOMAIN, ROOT_PATH, CANONICAL_PLATFORM } from './build-constants.mjs';
+
+// Agent Skills Discovery RFC v0.2.0 well-known location.
+const INDEX_SUBPATH = '.well-known/agent-skills/index.json';
+const SCHEMA_URL = 'https://agentskills.io/schema/v0.2.0/index.json';
+
+/**
+ * The agent skills AWS Amplify publishes. These are real, maintained artifacts
+ * in the awslabs/agent-plugins repository, surfaced to users through the
+ * Claude Code and Cursor plugin marketplaces and documented under
+ * /develop-with-ai/agent-plugins.
+ *
+ * `name` and `description` mirror the upstream SKILL.md frontmatter so the
+ * index stays consistent with the source of truth. `url` points at the docs
+ * page that explains how to install and use the skill rather than at a raw
+ * artifact, so no sha256 digest is published (the docs page is discovery and
+ * install guidance, not the downloadable artifact itself).
+ *
+ * @param {string} domain Site origin to build doc URLs against
+ * @returns {Array