Skip to content

Enhance llms.txt discovery by injecting HTTP Link headers and <link> …#724

Open
Thushani-Jayasekera wants to merge 1 commit into
wso2:mainfrom
Thushani-Jayasekera:llmstxt-discovery
Open

Enhance llms.txt discovery by injecting HTTP Link headers and <link> …#724
Thushani-Jayasekera wants to merge 1 commit into
wso2:mainfrom
Thushani-Jayasekera:llmstxt-discovery

Conversation

@Thushani-Jayasekera

@Thushani-Jayasekera Thushani-Jayasekera commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Problem

AI agents given just a portal URL (e.g. https://localhost:3000/aiorg/views/default) had no way to automatically discover the llms.txt file at
/{orgName}/views/{viewName}/llms.txt. The root /llms.txt already existed but wasn't signaled anywhere.


What We Discussed

Why agents couldn't auto-discover llms.txt:

  • The file lives at a deep path, not the domain root
  • No HTTP or HTML signals were pointing to it
  • robots.txt only had a vague placeholder comment
  • tags and Link headers aren't automatically followed by most LLMs today (including Claude) — they require an explicit prompt or tool

that respects those conventions

  • The most reliable discovery mechanism is the root /llms.txt convention (from llmstxt.org), which some tools like Cursor and Perplexity
    already check automatically

What we ruled out:

  • Meta tags — no recognized standard for llms.txt, would be ignored
  • JS-injected tag — useless for curl/headless agents
  • The tag alone — agents don't automatically follow it

Fixes Applied

  1. robots.txt — points agents to /llms.txt

User-agent: *
Allow: /

AI agent entry point: https://your-domain/llms.txt

  1. Root / middleware — signals /llms.txt

Any agent hitting the root URL now gets:

  • HTTP header: Link: </llms.txt>; rel="describedby"; type="text/plain"
  • HTML: injected before server-side
  1. Portal view middleware — signals the view-specific llms.txt

Any page under /:orgName/views/:viewName/* now gets:

  • HTTP header: Link: </aiorg/views/default/llms.txt>; rel="describedby"; type="text/plain"
  • HTML: injected server-side (no script tag)

Both 2 and 3 are handled by response-intercepting middleware in app.js — no template or controller changes needed.


Discovery Chain for an Agent

GET / → Link header + tag → /llms.txt
GET /llms.txt → root index, instructs agent to fetch /{orgName}/views/{viewName}/llms.txt
GET /aiorg/views/default → Link header + tag → /aiorg/views/default/llms.txt
GET /aiorg/views/default/llms.txt → full API catalog


Claude (claude.ai / Claude Code)

Does NOT auto-discover. When given a URL, I fetch the page and read the HTML text content — but I don't automatically follow Link headers or

tags, and I don't check /llms.txt first by default. The user needs to either: - Explicitly tell me to read the llms.txt URL, OR - Have the llms.txt URL visible as readable text in the page content

That's exactly what your home-discover.js does — it generates a prompt that tells Claude where to find llms.txt. That's the right workaround
for now.


Cursor

Partial support. Cursor's @docs feature can index documentation sites. If you add a portal URL, it crawls it. Whether it automatically picks up
llms.txt depends on the version — newer versions have added llms.txt awareness for their docs indexer. The tag and Link header we added
would help here.


Codex (OpenAI)

No auto-discovery. Similar to Claude — it doesn't check /llms.txt automatically. Needs an explicit prompt or system instruction pointing to the
URL.


Bottom Line

Tool | Auto-checks /llms.txt | Follows Link header | Needs explicit prompt -- | -- | -- | -- Claude | No | No | Yes Cursor (@docs) | Partially | Possibly | Sometimes Codex | No | No | Yes

The llms.txt standard is still early. The most reliable path for all three today is what you already have — the "Run in Claude" / copy prompt
flow in home-discover.js that gives the agent the exact URL. The signals we added (Link header, tag, root /llms.txt) are future-proofing
for when tools adopt the standard more broadly.

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Changes

This PR enhances llms.txt discovery for AI agents by introducing server-side signals through HTTP headers and HTML links, along with an updated robots.txt file.

robots.txt Enhancement

Updated the /robots.txt endpoint to provide a unified AI agent entry point reference. The response now includes a comment directing agents to {baseUrl}/llms.txt as the primary discovery endpoint, replacing any organization/view-specific path references.

Root Page Discovery Middleware

Added response-intercepting middleware for the root path (/) that:

  • Sets an HTTP Link header: </llms.txt>; rel="describedby"; type="text/plain"
  • Injects a corresponding HTML <link rel="alternate" type="text/plain" href="/llms.txt"> tag before the closing </head> tag

Organization/View Page Discovery Middleware

Added response-intercepting middleware for portal view pages (/:orgName/views/:viewName) that:

  • Sets an HTTP Link header with the organization/view-specific llms.txt path: </{orgName}/views/{viewName}/llms.txt>; rel="describedby"; type="text/plain"
  • Injects the corresponding HTML <link> tag with the organization/view-specific URL before the closing </head> tag

Both middleware implementations monkey-patch the res.send method to conditionally inject the HTML link tags only when the response body contains HTML, ensuring the changes do not affect non-HTML responses.

Implementation Details

  • All changes are contained within src/app.js as middleware and route configuration
  • No template, controller, or view file modifications were required
  • The existing prompt-based discovery mechanism remains in place as the primary reliable approach for current AI agents

Walkthrough

The change updates the robots.txt endpoint to reference a single, centralized AI agent entry point at ${config.baseUrl || ''}/llms.txt. Additionally, two new Express middlewares are introduced to handle HTTP Link headers and HTML content injection for the root path (/) and organization view pages (/:orgName/views/:viewName). Each middleware sets a rel="describedby" Link header and modifies HTML responses by injecting corresponding <link rel="alternate" type="text/plain"> tags before the closing </head> marker.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides comprehensive context on the problem, solution, and discovery chain. However, it lacks critical template sections like test coverage, security checks, and test environment details. Add missing sections: Automation tests (unit and integration test details), Security checks (secure coding and FindSecurityBugs verification), and Test environment (platforms tested). These are essential for pre-merge verification.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding HTTP Link headers and link tags for llms.txt discovery. It directly reflects the core technical implementation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/app.js (2)

375-375: ⚡ Quick win

Prefer res.append('Link', ...) instead of res.set('Link', ...).

Current code overwrites any existing Link header on these responses. Appending is safer for compatibility with other middleware/handlers.

Suggested change
-    res.set('Link', '</llms.txt>; rel="describedby"; type="text/plain"');
+    res.append('Link', '</llms.txt>; rel="describedby"; type="text/plain"');
...
-    res.set('Link', `<${llmsUrl}>; rel="describedby"; type="text/plain"`);
+    res.append('Link', `<${llmsUrl}>; rel="describedby"; type="text/plain"`);

As per coding guidelines, "Provide concise, actionable feedback focused on correctness and best practices."

Also applies to: 391-391

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app.js` at line 375, Replace the use of res.set('Link', ...) with
res.append('Link', ...) wherever the Link header is added (e.g., the occurrences
using res.set('Link', '</llms.txt>; rel="describedby"; type="text/plain"') and
the similar call later) so existing Link headers aren’t overwritten by this
middleware; update those calls to call res.append('Link', ...) instead to
preserve other handlers' Link values.

378-379: ⚡ Quick win

Restrict HTML injection to HTML responses.

The current rewrite triggers on any string body containing </head>. Add a content-type check (for example, text/html) to avoid unintended mutation of non-HTML responses.

As per coding guidelines, "Provide concise, actionable feedback focused on correctness and best practices."

Also applies to: 394-395

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app.js` around lines 378 - 379, The middleware currently rewrites any
string variable named body whenever it contains '</head>'; restrict this to only
HTML responses by checking the response Content-Type before modifying body—e.g.,
verify the response header (res.getHeader('content-type') or
headers['content-type']) includes 'text/html' and only then perform the
body.replace call that injects before '</head>'; apply the same Content-Type
guard to both locations where body is rewritten (the block around the shown
body.replace at lines ~378-379 and the similar replacement at ~394-395).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app.js`:
- Around line 389-396: The route builds llmsUrl using raw req.params (orgName,
viewName) which can produce invalid headers/HTML; encode the parameters before
composing the URL and using it in res.set and the injected href. Replace direct
uses of orgName and viewName with encoded values (e.g., encodedOrgName =
encodeURIComponent(orgName), encodedViewName = encodeURIComponent(viewName)),
then build llmsUrl from those encoded variables and use that llmsUrl in
res.set('<Link...>') and inside the res.send replacement so both the Link header
and the injected <link rel="alternate"... href="..."> use the encoded URL (refer
to symbols: orgName, viewName, llmsUrl, res.set, res.send, originalSend).

---

Nitpick comments:
In `@src/app.js`:
- Line 375: Replace the use of res.set('Link', ...) with res.append('Link', ...)
wherever the Link header is added (e.g., the occurrences using res.set('Link',
'</llms.txt>; rel="describedby"; type="text/plain"') and the similar call later)
so existing Link headers aren’t overwritten by this middleware; update those
calls to call res.append('Link', ...) instead to preserve other handlers' Link
values.
- Around line 378-379: The middleware currently rewrites any string variable
named body whenever it contains '</head>'; restrict this to only HTML responses
by checking the response Content-Type before modifying body—e.g., verify the
response header (res.getHeader('content-type') or headers['content-type'])
includes 'text/html' and only then perform the body.replace call that injects
before '</head>'; apply the same Content-Type guard to both locations where body
is rewritten (the block around the shown body.replace at lines ~378-379 and the
similar replacement at ~394-395).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 762473c6-2bf8-4797-8c07-e0f2119f3073

📥 Commits

Reviewing files that changed from the base of the PR and between aff2c36 and d32086d.

📒 Files selected for processing (1)
  • src/app.js

Comment thread src/app.js
Comment on lines +389 to +396
const { orgName, viewName } = req.params;
const llmsUrl = `/${orgName}/views/${viewName}/llms.txt`;
res.set('Link', `<${llmsUrl}>; rel="describedby"; type="text/plain"`);
const originalSend = res.send.bind(res);
res.send = function (body) {
if (typeof body === 'string' && body.includes('</head>')) {
body = body.replace('</head>',
` <link rel="alternate" type="text/plain" href="${llmsUrl}">\n</head>`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if src/app.js exists and examine the relevant lines
if [ -f "src/app.js" ]; then
  echo "=== Lines 380-410 of src/app.js ==="
  sed -n '380,410p' src/app.js | cat -n
else
  echo "File not found, searching for app.js"
  find . -name "app.js" -type f | head -20
fi

Repository: wso2/api-developer-portal-core

Length of output: 1688


🏁 Script executed:

# Search for the specific code pattern to verify location and context
rg "orgName.*viewName" -A 10 -B 5 --context=15

Repository: wso2/api-developer-portal-core

Length of output: 50387


🏁 Script executed:

# Check if encodeURIComponent is used elsewhere in the codebase for similar purposes
rg "encodeURIComponent" -B 2 -A 2

Repository: wso2/api-developer-portal-core

Length of output: 50387


Encode route parameters before building the URL for headers and HTML output.

The orgName and viewName parameters should be encoded before insertion into the HTTP Link header and HTML href attribute to ensure well-formed and safe output.

Suggested change
const { orgName, viewName } = req.params;
+const safeOrgName = encodeURIComponent(orgName);
+const safeViewName = encodeURIComponent(viewName);
-const llmsUrl = `/${orgName}/views/${viewName}/llms.txt`;
+const llmsUrl = `/${safeOrgName}/views/${safeViewName}/llms.txt`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { orgName, viewName } = req.params;
const llmsUrl = `/${orgName}/views/${viewName}/llms.txt`;
res.set('Link', `<${llmsUrl}>; rel="describedby"; type="text/plain"`);
const originalSend = res.send.bind(res);
res.send = function (body) {
if (typeof body === 'string' && body.includes('</head>')) {
body = body.replace('</head>',
` <link rel="alternate" type="text/plain" href="${llmsUrl}">\n</head>`);
const { orgName, viewName } = req.params;
const safeOrgName = encodeURIComponent(orgName);
const safeViewName = encodeURIComponent(viewName);
const llmsUrl = `/${safeOrgName}/views/${safeViewName}/llms.txt`;
res.set('Link', `<${llmsUrl}>; rel="describedby"; type="text/plain"`);
const originalSend = res.send.bind(res);
res.send = function (body) {
if (typeof body === 'string' && body.includes('</head>')) {
body = body.replace('</head>',
` <link rel="alternate" type="text/plain" href="${llmsUrl}">\n</head>`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app.js` around lines 389 - 396, The route builds llmsUrl using raw
req.params (orgName, viewName) which can produce invalid headers/HTML; encode
the parameters before composing the URL and using it in res.set and the injected
href. Replace direct uses of orgName and viewName with encoded values (e.g.,
encodedOrgName = encodeURIComponent(orgName), encodedViewName =
encodeURIComponent(viewName)), then build llmsUrl from those encoded variables
and use that llmsUrl in res.set('<Link...>') and inside the res.send replacement
so both the Link header and the injected <link rel="alternate"... href="...">
use the encoded URL (refer to symbols: orgName, viewName, llmsUrl, res.set,
res.send, originalSend).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant