Duvlify
GitHubGet started
AgentsServing agents

Serving agents

Duvlify defines six agent surfaces and four tools once, then adapts them to MCP, plain HTTP and WebMCP so they always agree.

People can read this site. Agents can call it. These are different jobs, so the site serves them through different surfaces.

SurfaceWho uses itWhat it is
llms.txt, llms-full.txtcrawlers, training pipelinesa map of the corpus and the corpus itself, as text
<page>.md, Accept: text/markdownanything that follows a linkevery page as clean Markdown
/mcpClaude, Cursor, ChatGPT, Claude Codethe docs as callable tools
/api/docs/*scripts, curl, anything without MCPthe same tools as plain GET
WebMCPagents running inside the visitor’s browserthe same tools again, registered on the page
/agent-manifest.jsonthe Worker, and anyone curiousthe outline of every page with line offsets

The first two rows are static. They exist in the build output and cost nothing to serve. The Worker serves the rest, and agents.enabled in src/docs.config.ts turns them all on together.

The four tools

worker/agent/tools.ts defines each tool once, as a name, a description, a JSON Schema and a handler. MCP, HTTP and WebMCP are three adapters over that one array. This is why they always agree on what search means. Adding a new tool takes one entry.

search(query, limit?)

Returns passages grouped by the page they came from.

JSON
{
  "query": "custom domain",
  "pages": [
    {
      "url": "https://docs.example.com/guides/deployment",
      "title": "Deploy your site",
      "description": "…",
      "type": "page",
      "tab": "Guides",
      "updated": "2026-08-03",
      "totalLines": 176,
      "chunks": [
        { "lines": [52, 74], "heading": "Host configuration",              "text": "…" },
        { "lines": [75, 97], "heading": "Host configuration › Caching",    "text": "…" }
      ]
    }
  ]
}

The response uses two different orderings, and both are intentional:

  • Pages follow relevance. The first passage of a page not yet seen creates that page at the next position. The order of pages is the ranking.
  • Chunks follow the page. An agent reading three passages from one page wants them in reading order. Sorting by relevance would scramble a procedure whose steps only make sense in sequence.

No score appears in the response, at either level. Neither retrieval backend produces a number an agent could interpret. A reciprocal-rank-fusion score is a sum of inverted ranks, not a similarity score. An uninterpretable number would only invite false confidence. Dropping it also lets the retriever use rrf, which ranks better than the alternative that would have produced a displayable figure.

lines and totalLines let an agent decide whether to call fetch. Three passages spanning lines 40 to 95 of a 260-line page cover a third of a scattered topic, so loading the whole page is worth it. One passage at lines 12 to 20 is not.

fetch(url)

This tool returns the complete Markdown of one page. The Markdown is byte-identical to what <page>.md serves and to what the index stores. It takes the url from search or list_pages. A bare path also works, so an agent that shortened a link still lands on the page.

Pages are identified by URL and nothing else. An internal id does exist. It is the join key between AI Search’s item.key and the build manifest, but it never appears in a response.

list_pages(tab?, type?, prefix?, updated_since?)

This tool lists the corpus without going through search. It shows what pages exist, what kind of page each one is, and when it last changed. An agent that calls an MCP server does not read llms.txt, so this tool is where that information has to live for the agent to see it. It reads entirely from the manifest, so it makes no retrieval call and costs nothing.

report_issue(page, …) — optional

This tool stays hidden from tools/list until agents.feedback.webhook is set. One declaration defines both the fields the agent must supply and the payload the webhook receives, so the two cannot drift apart:

TypeScript
feedback: {
  webhook: 'https://example.com/hooks/docs-feedback',
  fields: [
    { name: 'problem', type: 'string', required: true, maxLength: 2000, description: '…' },
    { name: 'kind', type: 'enum', required: false, values: ['inaccurate', 'outdated', 'missing', 'unclear'] },
  ],
  context: ['page', 'pageId', 'agent', 'siteVersion', 'reportedAt'],
}

fields is what the agent supplies. context is what the Worker adds, and the agent can neither supply it nor forge it. If delivery fails, the tool reports the failure back to the agent as a message instead of throwing an error. A feedback tool must never break the conversation that called it.

Use these docs with your AI tools

An AI agent can read this documentation directly. You do not need an account or an API key. Everything here is public and read-only.

Query these docs via MCP

Recommended

Add this server to Claude, Claude Code, Cursor, Mistral, or any tool that supports MCP. Your agent can then search Duvlify documentation and read it in full, instead of answering from memory.

https://duvlify.dev/mcp
  • searchFind the passages that answer a question.
  • fetchRead one page in full, as Markdown.
  • list_pagesSee every page in this documentation.

Query these docs over HTTP

The same tools also work as plain web requests. Use this for scripts, or for any tool that does not support MCP. There is one endpoint per tool. Arguments go in the query string, and the answer comes back as JSON.

https://duvlify.dev/api/docs/search?query=custom+domain

Read the OpenAPI description. It is built from the same definitions as the tools, so it always matches what the endpoints do.

Read these docs as Markdown

Add .md to any page URL to get its Markdown source. You can also send the headerAccept: text/markdown to the page URL itself.

To read the whole documentation in one file, open llms-full.txt. For a short index of every page, open llms.txt.