Skip to content

Commit bdff2d1

Browse files
committed
add agent-ready content negotiation
1 parent adf1e39 commit bdff2d1

17 files changed

Lines changed: 374 additions & 280 deletions

app/agent-markdown/route.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { markdownForPath } from '../../lib/agent-markdown'
2+
import { contentSignal } from '../../data/profile'
3+
4+
export const dynamic = 'force-dynamic'
5+
6+
export async function GET(request) {
7+
const path =
8+
request.headers.get('x-agent-markdown-path') ??
9+
new URL(request.url).searchParams.get('path') ??
10+
'/'
11+
const markdown = await markdownForPath(path)
12+
const headers = {
13+
'Content-Signal': contentSignal,
14+
'Content-Type': 'text/markdown; charset=utf-8',
15+
Vary: 'Accept',
16+
'X-Robots-Tag': 'noindex, follow'
17+
}
18+
19+
if (markdown === null)
20+
return new Response('Not Found\n', { status: 404, headers })
21+
22+
return new Response(markdown, { headers })
23+
}

app/robots.js

Lines changed: 0 additions & 8 deletions
This file was deleted.

app/robots.txt/route.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { contentSignal, siteUrl } from '../../data/profile'
2+
3+
export const dynamic = 'force-static'
4+
5+
export function GET() {
6+
return new Response(
7+
`User-agent: *\nContent-Signal: ${contentSignal}\nAllow: /\nDisallow: /agent-markdown\n\nSitemap: ${siteUrl}/sitemap.xml\n`,
8+
{
9+
headers: {
10+
'Content-Signal': contentSignal,
11+
'Content-Type': 'text/plain; charset=utf-8'
12+
}
13+
}
14+
)
15+
}

data/profile.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export const siteUrl = 'https://mohsinht.com'
2+
export const siteLastUpdated = '2026-08-15'
3+
export const contentSignal =
4+
'search=yes, ai-input=yes, ai-train=no, use=reference'
25

36
export const profile = {
47
name: 'Mohsin Hayat',

docs/launch-checklist.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ This branch is ready to deploy only after the following external actions are com
1010
4. After deployment, verify `/`, `/resume`, `/recommendations`, `/posts`, `/sitemap.xml`, `/robots.txt`, `/feed.xml`, `/llms.txt`, `/llms-full.txt`, and the `/llm.txt` redirect.
1111
5. Inspect production response headers. Confirm the CSP, HSTS, Referrer-Policy, Permissions-Policy, `X-Content-Type-Options`, and PDF `X-Robots-Tag` headers are present. Check the browser console for CSP errors.
1212

13+
## Cloudflare agent readiness
14+
15+
1. In **AI Crawl Control**, allow search and user-requested retrieval crawlers, and block model-training crawlers only if that matches the published `Content-Signal` policy. This is an enforcement setting; `robots.txt` and response headers are voluntary declarations.
16+
2. If the Cloudflare plan supports it, enable **AI Crawl Control → Markdown for Agents**. The origin already serves Markdown for `Accept: text/markdown`, so do not enable a conflicting rewrite or cache rule.
17+
3. Verify the origin after deployment:
18+
- `curl -I https://mohsinht.com/` returns `200` and includes `Content-Signal: search=yes, ai-input=yes, ai-train=no, use=reference`.
19+
- `curl https://mohsinht.com/robots.txt` includes the same signal and only `https://mohsinht.com/sitemap.xml` as a sitemap.
20+
- `curl -sD - -H 'Accept: text/markdown' https://mohsinht.com/` returns `Content-Type: text/markdown; charset=utf-8` and `Vary: Accept`.
21+
- Repeat the Markdown request for `/resume`, `/recommendations`, `/posts`, and each public article.
22+
1323
## Google Search Console
1424

1525
1. Verify the domain property, and verify host variants separately if the account setup requires it.

e2e/portfolio.spec.js

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,31 @@ test('navigation, feed discovery, and internal routes work', async ({
6767
test('utility endpoints and redirects have expected search behavior', async ({
6868
request
6969
}) => {
70-
const [robots, sitemap, feed, llms, full, redirect, earlier, missing] =
71-
await Promise.all([
72-
request.get('/robots.txt'),
73-
request.get('/sitemap.xml'),
74-
request.get('/feed.xml'),
75-
request.get('/llms.txt'),
76-
request.get('/llms-full.txt'),
77-
request.get('/llm.txt', { maxRedirects: 0 }),
78-
request.get('/earlier-work', { maxRedirects: 0 }),
79-
request.get('/not-a-public-page')
80-
])
70+
const [
71+
robots,
72+
sitemap,
73+
feed,
74+
llms,
75+
full,
76+
markdown,
77+
articleMarkdown,
78+
redirect,
79+
earlier,
80+
missing
81+
] = await Promise.all([
82+
request.get('/robots.txt'),
83+
request.get('/sitemap.xml'),
84+
request.get('/feed.xml'),
85+
request.get('/llms.txt'),
86+
request.get('/llms-full.txt'),
87+
request.get('/', { headers: { Accept: 'text/markdown' } }),
88+
request.get('/posts/working-remotely-from-pakistan', {
89+
headers: { Accept: 'text/markdown' }
90+
}),
91+
request.get('/llm.txt', { maxRedirects: 0 }),
92+
request.get('/earlier-work', { maxRedirects: 0 }),
93+
request.get('/not-a-public-page')
94+
])
8195
await expect(robots).toBeOK()
8296
await expect(sitemap).toBeOK()
8397
await expect(feed).toBeOK()
@@ -86,10 +100,18 @@ test('utility endpoints and redirects have expected search behavior', async ({
86100
expect(await robots.text()).toContain(
87101
'Sitemap: https://mohsinht.com/sitemap.xml'
88102
)
103+
expect(await robots.text()).toContain('Content-Signal: search=yes')
89104
expect(await sitemap.text()).not.toContain('earlier-work')
90105
expect(await feed.text()).toContain('xmlns:atom')
91106
expect(await llms.text()).toContain('# Mohsin Hayat')
92107
expect(await full.text()).toContain('Last updated:')
108+
expect(markdown.headers()['content-type']).toContain('text/markdown')
109+
expect(markdown.headers().vary).toContain('Accept')
110+
expect(markdown.headers()['content-signal']).toContain('ai-input=yes')
111+
expect(await markdown.text()).toContain('# Mohsin Hayat')
112+
expect(await articleMarkdown.text()).toContain(
113+
'# Working Remotely from Pakistan: A 2022 Retrospective'
114+
)
93115
expect(redirect.status()).toBe(308)
94116
expect(redirect.headers().location).toBe('/llms.txt')
95117
expect(earlier.status()).toBe(308)

lib/agent-markdown.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { readFile } from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { profile, recommendationUrl, siteUrl } from '../data/profile'
5+
6+
const rootDirectory = path.resolve(
7+
path.dirname(fileURLToPath(import.meta.url)),
8+
'..'
9+
)
10+
11+
function markdownLink(label, href) {
12+
return `[${label}](${href})`
13+
}
14+
15+
function homeMarkdown() {
16+
return `# ${profile.name}
17+
18+
> ${profile.title}
19+
20+
${profile.summary}
21+
22+
Based in ${profile.location}, I have more than six years of international remote engineering experience. I currently work at OnService.AI on Python services and conversational AI for airline workflows.
23+
24+
## Selected work
25+
26+
${profile.projects
27+
.slice(0, 2)
28+
.map(
29+
(project) =>
30+
`### ${project.name}\n\n${project.homepageSummary}\n\n${project.homepageDetail}\n\n${markdownLink(`Visit ${project.name}`, project.href)}`
31+
)
32+
.join('\n\n')}
33+
34+
## Professional links
35+
36+
- ${markdownLink('Résumé', `${siteUrl}/resume`)}
37+
- ${markdownLink('Recommendations', `${siteUrl}/recommendations`)}
38+
- ${markdownLink('Engineering writing', `${siteUrl}/posts`)}
39+
- ${markdownLink('GitHub', profile.links.github)}
40+
- ${markdownLink('LinkedIn', profile.links.linkedin)}
41+
- ${markdownLink('Email', profile.links.email)}
42+
`
43+
}
44+
45+
function resumeMarkdown() {
46+
const roles = profile.roles
47+
.map(
48+
(role) =>
49+
`### ${role.company}${role.title}\n\n${role.period}; ${role.location}\n\n${role.summary}`
50+
)
51+
.join('\n\n')
52+
const skills = Object.entries(profile.skills)
53+
.map(([group, values]) => `- **${group}:** ${values.join(', ')}`)
54+
.join('\n')
55+
56+
return `# ${profile.name} Résumé
57+
58+
> ${profile.title}
59+
60+
${profile.location}
61+
62+
## Summary
63+
64+
${profile.description}
65+
66+
## Experience
67+
68+
${roles}
69+
70+
## Skills
71+
72+
${skills}
73+
74+
## Education and achievements
75+
76+
${profile.education}
77+
78+
${profile.achievements.map((achievement) => `- ${achievement}`).join('\n')}
79+
`
80+
}
81+
82+
function recommendationsMarkdown() {
83+
const linkedinRecommendations = profile.recommendations
84+
.map(
85+
(item) =>
86+
`### ${item.company}\n\n> ${item.excerpt}\n>
87+
> — ${item.name}, ${item.role}${item.additionalRole ? `; ${item.additionalRole}` : ''}\n\n${markdownLink('View LinkedIn recommendation', recommendationUrl)}`
88+
)
89+
.join('\n\n')
90+
const employerReferences = profile.employerRecommendations
91+
.map(
92+
(item) =>
93+
`### ${item.company}\n\n> ${item.excerpt}\n>
94+
> — ${item.name}, ${item.role}\n\n${markdownLink(item.linkLabel, `${siteUrl}${item.href}`)}`
95+
)
96+
.join('\n\n')
97+
const testimonials = profile.clientTestimonials
98+
.map(
99+
(item) =>
100+
`### ${item.project}\n\n> ${item.excerpt}\n>
101+
> — ${item.name} (${item.role.replace('Client feedback · ', '')})`
102+
)
103+
.join('\n\n')
104+
105+
return `# Recommendations for ${profile.name}
106+
107+
## Verified LinkedIn recommendations
108+
109+
${linkedinRecommendations}
110+
111+
## Employer references
112+
113+
${employerReferences}
114+
115+
## Clients’ testimonials
116+
117+
${testimonials}
118+
119+
## Certifications
120+
121+
${profile.certifications
122+
.map(
123+
(item) =>
124+
`- **${item.name}** — ${item.issuer}, ${item.date}. ${markdownLink(item.linkLabel, `${siteUrl}${item.href}`)}`
125+
)
126+
.join('\n')}
127+
`
128+
}
129+
130+
async function postsMarkdown() {
131+
const { getPosts } = await import('../app/posts/get-posts')
132+
const posts = await getPosts()
133+
return `# Engineering Writing | ${profile.name}
134+
135+
Dated personal writing by ${profile.name} on engineering, remote work, and software careers.
136+
137+
${posts
138+
.map(
139+
(post) =>
140+
`- ${markdownLink(post.frontMatter.title, `${siteUrl}${post.route}`)}${post.frontMatter.description}`
141+
)
142+
.join('\n')}
143+
`
144+
}
145+
146+
async function articleMarkdown(slug) {
147+
if (!/^[a-z0-9-]+$/.test(slug)) return null
148+
149+
try {
150+
const source = await readFile(
151+
path.join(rootDirectory, 'app', 'posts', slug, 'page.md'),
152+
'utf8'
153+
)
154+
const match = source.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
155+
if (!match) return null
156+
const title = match[1].match(/^title:\s*['"]?(.+?)['"]?$/m)?.[1]
157+
if (!title) return null
158+
return `# ${title}\n\n${match[2].trim()}\n`
159+
} catch {
160+
return null
161+
}
162+
}
163+
164+
export async function markdownForPath(requestPath) {
165+
const pathname = requestPath.split('?')[0].replace(/\/$/, '') || '/'
166+
if (pathname === '/') return homeMarkdown()
167+
if (pathname === '/resume') return resumeMarkdown()
168+
if (pathname === '/recommendations') return recommendationsMarkdown()
169+
if (pathname === '/posts') return postsMarkdown()
170+
if (pathname.startsWith('/posts/'))
171+
return articleMarkdown(pathname.slice('/posts/'.length))
172+
return null
173+
}

lib/public-content.js

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
1-
import { profile, siteUrl } from '../data/profile'
1+
import { profile, siteLastUpdated, siteUrl } from '../data/profile'
22

33
export const staticPages = [
44
{
55
path: '/',
6-
lastModified: '2026-08-10',
7-
changeFrequency: 'monthly',
8-
priority: 1
6+
lastModified: siteLastUpdated
97
},
108
{
119
path: '/resume',
12-
lastModified: '2026-08-10',
13-
changeFrequency: 'monthly',
14-
priority: 0.9
10+
lastModified: '2026-08-10'
1511
},
1612
{
1713
path: '/recommendations',
18-
lastModified: '2026-08-10',
19-
changeFrequency: 'monthly',
20-
priority: 0.8
14+
lastModified: siteLastUpdated
2115
},
2216
{
2317
path: '/posts',
24-
lastModified: '2026-08-10',
25-
changeFrequency: 'monthly',
26-
priority: 0.8
18+
lastModified: '2026-08-10'
2719
}
2820
]
2921

@@ -36,9 +28,7 @@ export function publicSitemapEntries(posts) {
3628
url: new URL(post.route, siteUrl).toString(),
3729
lastModified: new Date(
3830
post.frontMatter.dateModified ?? post.frontMatter.date
39-
),
40-
changeFrequency: 'yearly',
41-
priority: 0.7
31+
)
4232
}))
4333
return [...pages, ...postEntries]
4434
}
@@ -73,5 +63,5 @@ export function llmsFull() {
7363
const recommendations = profile.recommendations
7464
.map((item) => `- **${item.name}**, ${item.role}: “${item.excerpt}”`)
7565
.join('\n')
76-
return `# ${profile.name}\n\n> ${profile.title}\n\nLast updated: 2026-08-10\n\n${section('Professional summary', profile.description)}${section('Experience', roles)}${section('Verified engineering context', metrics)}${section('Skills', skills)}${section('Selected projects', projects)}${section('Education and achievements', `${profile.education}\n\n${profile.achievements.map((item) => `- ${item}`).join('\n')}`)}${section('Verified recommendation excerpts', recommendations)}${section('Public contact and profiles', `- Email: ${profile.email}\n- GitHub: ${profile.links.github}\n- LinkedIn: ${profile.links.linkedin}\n- Website: ${siteUrl}`)}`
66+
return `# ${profile.name}\n\n> ${profile.title}\n\nLast updated: ${siteLastUpdated}\n\n${section('Professional summary', profile.description)}${section('Experience', roles)}${section('Verified engineering context', metrics)}${section('Skills', skills)}${section('Selected projects', projects)}${section('Education and achievements', `${profile.education}\n\n${profile.achievements.map((item) => `- ${item}`).join('\n')}`)}${section('Verified recommendation excerpts', recommendations)}${section('Public contact and profiles', `- Email: ${profile.email}\n- GitHub: ${profile.links.github}\n- LinkedIn: ${profile.links.linkedin}\n- Website: ${siteUrl}`)}`
7767
}

next.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import nextra from 'nextra'
2+
import { contentSignal } from './data/profile.js'
23

34
const withNextra = nextra({ readingTime: true })
45
const developmentScriptSource =
@@ -9,6 +10,7 @@ const securityHeaders = [
910
key: 'Content-Security-Policy',
1011
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${developmentScriptSource}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'`
1112
},
13+
{ key: 'Content-Signal', value: contentSignal },
1214
{
1315
key: 'Strict-Transport-Security',
1416
value: 'max-age=31536000; includeSubDomains'

0 commit comments

Comments
 (0)