Blog · GEO

llms.txt: o que é e como criar, gerar e validar (exemplo em Next.js)

Capiva · Outubro 2026 · 10 min de leitura

O llms.txt é um arquivo Markdown servido em /llms.txt que resume o site pra modelos de linguagem e agentes: um título, uma frase do que é o projeto e listas de links pras páginas que importam, cada uma com uma nota curta. A proposta, de Jeremy Howard, define o formato em poucas linhas, e a expectativa descrita nela é que o agente leia ou pesquise o arquivo pra achar o que precisa e depois siga os links relevantes.

Este post é só prático: escrever o arquivo, gerar a partir de uma lista curada, validar com um script e publicar num site Next.js, incluindo a novidade da v2. Estratégia de visibilidade em IA fica no pilar de GEO.

Entenda o formato em cinco regras

Pela especificação, nesta ordem:

  1. Um H1 com o nome do site ou projeto. É a única parte obrigatória.
  2. Um blockquote com um resumo curto, com as informações-chave pra entender o resto.
  3. Zero ou mais blocos de texto de qualquer tipo, menos títulos, com detalhes.
  4. Zero ou mais seções H2 com "listas de arquivos".
  5. Cada item da lista é um link Markdown obrigatório, [nome](url), opcionalmente seguido de : e uma nota.
# Nome do site

> Uma ou duas frases dizendo o que é, pra quem e o que tem aqui.

Detalhes que ajudam a ler o resto. Sem títulos.

## Seção

- [Página](https://exemplo.com/pagina): nota curta sobre o que tem nela

Duas mudanças da v2, de agosto de 2026, que tutoriais antigos ainda não refletem:

  • A seção Optional perdeu o significado especial. Na v1, ela dizia pra ferramenta llms_txt2ctx o que cortar ao montar o contexto. A ferramenta saiu da proposta. A seção continua permitida, como convenção pra links secundários.
  • Agora existe descoberta. Uma página pode apontar pro llms.txt que a descreve com rel="describedby", e pra sua versão em Markdown com rel="alternate" type="text/markdown", via <link> no HTML ou header HTTP Link:.

Liste as páginas num arquivo curado

O llms.txt é uma seleção, e um arquivo que lista tudo não ajuda o agente a escolher nada. Comece por uma lista curta, num YAML que alguém revisa:

site: Capiva
resumo: >-
  Consultoria boutique de IA (Brasil, EUA e Reino Unido). Combina estratégia de
  negócio com execução técnica: diagnóstico, prova de conceito com dados reais e
  harness engineering para agentes em produção.
detalhes: |
  - Conteúdo em português em /pt/ e em inglês em /en/, com os mesmos slugs.
  - Para dúvidas sobre preço e escopo, use as páginas de serviço; o blog é educativo.
secoes:
  Serviços:
    - titulo: Diagnóstico Estratégico
      url: https://capiva.tech/pt/strategic-diagnosis
      nota: investigação de 3 a 8 semanas que mapeia onde IA cria valor na operação
    - titulo: Validação Técnica
      url: https://capiva.tech/pt/technical-validation
      nota: prova de conceito com dados reais antes do investimento maior
  Guias:
    - titulo: O que é um AI Harness
      url: https://capiva.tech/pt/blog/o-que-e-ai-harness
      nota: post pilar do cluster de harness engineering
    - titulo: O que é GEO / SEO para IA
      url: https://capiva.tech/pt/blog/o-que-e-geo-seo-para-ia
      nota: post pilar do cluster de visibilidade em IA
  Optional:
    - titulo: Epidemia da Inutilidade Profissional
      url: https://capiva.tech/pt/resources/professional-uselessness-epidemic
      nota: ensaio interativo sobre IA e trabalho

A nota é o que o agente lê pra decidir se segue o link. "Saiba mais" não diz nada. "Investigação de 3 a 8 semanas que mapeia onde IA cria valor" diz.

Gere o arquivo

#!/usr/bin/env python3
"""Gera llms.txt a partir de um YAML curado.

Uso: python3 gerar_llms_txt.py paginas.yaml > public/llms.txt
"""

import sys

import yaml


def gerar(cfg: dict) -> str:
    partes = [f"# {cfg['site']}", "", f"> {' '.join(cfg['resumo'].split())}", ""]
    if cfg.get("detalhes"):
        partes += [cfg["detalhes"].strip(), ""]
    for secao, paginas in cfg["secoes"].items():
        partes.append(f"## {secao}")
        partes.append("")
        for p in paginas:
            linha = f"- [{p['titulo']}]({p['url']})"
            if p.get("nota"):
                linha += f": {p['nota']}"
            partes.append(linha)
        partes.append("")
    return "\n".join(partes)


if __name__ == "__main__":
    with open(sys.argv[1], encoding="utf-8") as f:
        sys.stdout.write(gerar(yaml.safe_load(f)))
pip install pyyaml
python3 gerar_llms_txt.py paginas.yaml > public/llms.txt

Saída:

# Capiva

> Consultoria boutique de IA (Brasil, EUA e Reino Unido). Combina estratégia de negócio com execução técnica: diagnóstico, prova de conceito com dados reais e harness engineering para agentes em produção.

- Conteúdo em português em /pt/ e em inglês em /en/, com os mesmos slugs.
- Para dúvidas sobre preço e escopo, use as páginas de serviço; o blog é educativo.

## Serviços

- [Diagnóstico Estratégico](https://capiva.tech/pt/strategic-diagnosis): investigação de 3 a 8 semanas que mapeia onde IA cria valor na operação
- [Validação Técnica](https://capiva.tech/pt/technical-validation): prova de conceito com dados reais antes do investimento maior

## Guias

- [O que é um AI Harness](https://capiva.tech/pt/blog/o-que-e-ai-harness): post pilar do cluster de harness engineering
- [O que é GEO / SEO para IA](https://capiva.tech/pt/blog/o-que-e-geo-seo-para-ia): post pilar do cluster de visibilidade em IA

## Optional

- [Epidemia da Inutilidade Profissional](https://capiva.tech/pt/resources/professional-uselessness-epidemic): ensaio interativo sobre IA e trabalho

Repare na lista antes do primeiro H2. É permitida: a regra 3 só proíbe títulos ali. Já dentro de uma seção H2, todo item precisa ser link.

Valide antes de publicar

O formato é simples o bastante pra quebrar sem ninguém perceber: alguém adiciona um item sem link, uma página muda de URL, o separador vira travessão. Este validador checa a estrutura e, opcionalmente, o status HTTP de cada link (sem seguir redirecionamento, pra você ver o 301 em vez de mascarar):

#!/usr/bin/env python3
"""Valida um llms.txt contra o formato de llmstxt.org.

Uso: python3 validar_llms_txt.py ARQUIVO_OU_URL [--checar-links]
"""

import re
import sys
import urllib.request

ITEM = re.compile(r"^- \[(?P<nome>[^\]]+)\]\((?P<url>[^)\s]+)\)(?P<resto>.*)$")


def ler(origem: str) -> str:
    if origem.startswith("http"):
        req = urllib.request.Request(origem, headers={"User-Agent": "validar-llms-txt"})
        with urllib.request.urlopen(req, timeout=15) as r:
            return r.read().decode("utf-8-sig")
    with open(origem, encoding="utf-8-sig") as f:  # utf-8-sig: o BOM é permitido
        return f.read()


def status_http(url: str, metodo: str = "HEAD") -> str:
    class SemRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, *args, **kwargs):
            return None

    abridor = urllib.request.build_opener(SemRedirect)
    req = urllib.request.Request(
        url, method=metodo, headers={"User-Agent": "validar-llms-txt"}
    )
    try:
        with abridor.open(req, timeout=15) as r:
            return str(r.status)
    except urllib.error.HTTPError as e:
        if e.code == 405 and metodo == "HEAD":  # servidor que não aceita HEAD
            return status_http(url, "GET")
        return f"{e.code} -> {e.headers.get('Location', '')}".strip(" ->")
    except Exception as e:  # noqa: BLE001 (DNS, timeout, TLS)
        return f"falhou ({e.__class__.__name__})"


def validar(
    texto: str, checar_links: bool = False
) -> tuple[list[str], list[str], list[str]]:
    erros, avisos, links = [], [], []
    linhas = [li.rstrip() for li in texto.splitlines()]
    conteudo = [li for li in linhas if li.strip()]
    if not conteudo or not conteudo[0].startswith("# "):
        erros.append(
            "a primeira linha com conteúdo precisa ser um H1 ('# Nome do site')"
        )
    if sum(1 for li in linhas if li.startswith("# ")) > 1:
        erros.append("há mais de um H1")
    if len(conteudo) < 2 or not conteudo[1].startswith("> "):
        avisos.append(
            "sem blockquote de resumo logo após o H1 (opcional, mas recomendado)"
        )

    secao = None
    for n, li in enumerate(linhas, 1):
        if re.match(r"^#{3,} ", li):
            avisos.append(f"linha {n}: o formato só usa H1 e H2 ({li[:40]!r})")
        elif li.startswith("## "):
            secao = li[3:].strip()
        elif secao and li.startswith("- "):
            m = ITEM.match(li)
            if not m:
                erros.append(f"linha {n}: item de lista sem link markdown [nome](url)")
                continue
            links.append(m["url"])
            resto = m["resto"]
            if resto and not resto.startswith(":"):
                achei = resto[:3].strip()
                avisos.append(f"linha {n}: notas vêm depois de ':' (achei {achei!r})")
    tokens = len(texto) // 4
    if tokens > 10_000:
        avisos.append(
            f"~{tokens} tokens (chars/4): o arquivo deveria caber folgado no contexto"
        )
    if checar_links:
        for url in links:
            s = status_http(url)
            if s != "200":
                avisos.append(f"link {url} respondeu {s}")
    return erros, avisos, links


if __name__ == "__main__":
    origem = sys.argv[1]
    erros, avisos, links = validar(ler(origem), "--checar-links" in sys.argv)
    for e in erros:
        print("ERRO ", e)
    for a in avisos:
        print("AVISO", a)
    print(f"{len(links)} links, {len(erros)} erros, {len(avisos)} avisos")
    sys.exit(1 if erros else 0)
$ python3 validar_llms_txt.py public/llms.txt --checar-links
5 links, 0 erros, 0 avisos

O validador aceita URL também, então dá pra rodar contra produção. Sai com código 1 quando há erro, o que basta pra pôr no CI. O limite de 10 mil tokens (estimados por caracteres/4) é escolha minha, não da especificação; ajuste ao seu caso.

Publique no Next.js

Arquivo estático em public/ é servido na raiz. public/llms.txt vira https://seu-site/llms.txt, sem código.

Pra implementar a descoberta da v2, adicione o header Link nas páginas cobertas pelo arquivo:

/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        // Cada post aponta para o llms.txt que o descreve (link relation da v2 da proposta)
        source: '/pt/blog/:slug',
        headers: [{ key: 'Link', value: '</llms.txt>; rel="describedby"' }],
      },
    ]
  },
}

export default nextConfig

Testei num app Next.js 16.4 com next build && next start:

$ curl -sI http://localhost:3124/pt/blog/x | grep -i link
Link: </llms.txt>; rel="describedby"
$ curl -sI http://localhost:3124/llms.txt | grep -i content-type
Content-Type: text/plain; charset=UTF-8

text/plain é o que o Next.js serve pra .txt, e a especificação não pede outro tipo. A outra metade da v2, versões .md de cada página (pagina.md ou pagina.html.md), exige gerar Markdown do conteúdo. Fica pra quando o blog tiver essa saída.

Se o site tem áreas com donos diferentes, a v2 também define arquivos em subcaminho: um /docs/llms.txt cobre as páginas sob /docs/, e vale o arquivo mais específico.

Como usamos na Capiva

Rodei o validador contra o llms.txt da Capiva antes de escrever este post. Resultado: 35 links, 10 erros e 37 avisos. Os erros eram itens sem link dentro de seções H2 (Serviços, Fundador, Contato), escritos como texto em negrito. A maioria dos avisos era o separador: usávamos — em vez de : antes das notas. Dois links não respondiam 200: um apontava pra um domínio antigo que devolvia 404, e /en/blog redirecionava com 308. Corrigimos o arquivo e rodamos de novo com --checar-links: 34 links, 0 erros, 0 avisos. Em 12 de outubro de 2026, depois de consolidarmos quatro posts nos pilares, a mesma checagem deu 30 links, 0 erros e 0 avisos. Nenhum daqueles erros impedia um modelo de ler o arquivo. Eles mostram a deriva que acontece quando ninguém valida: um validador no CI acusa na hora, e quem olha o arquivo a olho não percebe.

O Lighthouse, aliás, tem uma auditoria de llms.txt: se o arquivo não existe (404), a auditoria fica como não aplicável; se o servidor dá erro ao buscá-lo, a página é sinalizada.

Checklist de troubleshooting

  • [ ] O arquivo não abre em /llms.txt. No Next.js, ele precisa estar em public/, não em app/. Confira se nenhum middleware ou rewrite captura a rota.
  • [ ] O validador acusa item sem link. Dentro de seção H2, todo item é - [nome](url). Texto solto vai antes do primeiro H2.
  • [ ] Links redirecionando. Use a URL final no arquivo. Cada redirecionamento é uma requisição a mais pro agente.
  • [ ] Acentos quebrados. Salve em UTF-8. O BOM é permitido pela especificação, e o validador aceita.
  • [ ] O header Link não aparece. O source do headers() precisa casar com a rota exata; teste com curl -I em produção, porque CDN pode descartar headers.
  • [ ] Arquivo enorme. Se passou de alguns milhares de linhas, você fez um sitemap. Corte pras páginas que respondem às perguntas de quem procura você.
  • [ ] O agente não acha a página nova. O llms.txt é estático. Gere no build a partir do YAML pra ele não ficar pra trás.

Perguntas frequentes

O llms.txt substitui o robots.txt ou o sitemap.xml?▼

Não. O robots.txt diz o que pode ser rastreado, o sitemap lista tudo pra indexação, e o llms.txt é uma seleção comentada pra leitura por modelos.

Preciso do llms-full.txt?▼

A especificação não define um llms-full.txt. Alguns sites publicam um com todo o conteúdo concatenado, como convenção própria.

Quem publica llms.txt?▼

A OpenAI e a Anthropic, pra documentação, por exemplo. Isso mostra adoção em sites de docs, não que buscadores usem o arquivo pra ranquear.

Garante que o ChatGPT vai citar meu site?▼

Não. O arquivo ajuda agentes a navegar seu conteúdo. Pra saber se você está sendo citado, meça.

Leia também

Quer saber como seu site aparece pra IA, llms.txt incluso? Faça o Diagnóstico GEO gratuito.